mirror of
https://github.com/the-djmaze/snappymail.git
synced 2024-09-20 15:45:55 +08:00
Use only allowed attributes in the html parser.
This commit is contained in:
parent
0a6a84b3d7
commit
5a01b59d40
|
@ -272,7 +272,7 @@ class HtmlUtils
|
||||||
$sHtml = \str_replace('<o:p></o:p>', '', $sHtml);
|
$sHtml = \str_replace('<o:p></o:p>', '', $sHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sHtml = \str_replace('<o:p>', '<span data-ms="o:p">', $sHtml);
|
$sHtml = \str_replace('<o:p>', '<span>', $sHtml);
|
||||||
$sHtml = \str_replace('</o:p>', '</span>', $sHtml);
|
$sHtml = \str_replace('</o:p>', '</span>', $sHtml);
|
||||||
|
|
||||||
return $sHtml;
|
return $sHtml;
|
||||||
|
@ -337,16 +337,23 @@ class HtmlUtils
|
||||||
$aRemoveTags[] = 'style';
|
$aRemoveTags[] = 'style';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$aHtmlAllowedTags = isset(\MailSo\Config::$HtmlStrictAllowedTags) &&
|
||||||
|
\is_array(\MailSo\Config::$HtmlStrictAllowedTags) && 0 < \count(\MailSo\Config::$HtmlStrictAllowedTags) ?
|
||||||
|
\MailSo\Config::$HtmlStrictAllowedTags : null;
|
||||||
|
|
||||||
$aRemove = array();
|
$aRemove = array();
|
||||||
$aNodes = $oDom->getElementsByTagName('*');
|
$aNodes = $oDom->getElementsByTagName('*');
|
||||||
foreach ($aNodes as /* @var $oElement \DOMElement */ $oElement)
|
foreach ($aNodes as /* @var $oElement \DOMElement */ $oElement)
|
||||||
{
|
{
|
||||||
if ($oElement)
|
if ($oElement)
|
||||||
{
|
{
|
||||||
$sTagNameLower = \strtolower($oElement->tagName);
|
$sTagNameLower = \trim(\strtolower($oElement->tagName));
|
||||||
if ('' !== $sTagNameLower && \in_array($sTagNameLower, $aRemoveTags))
|
if ('' !== $sTagNameLower)
|
||||||
{
|
{
|
||||||
$aRemove[] = @$oElement;
|
if (\in_array($sTagNameLower, $aRemoveTags) || ($aHtmlAllowedTags && !\in_array($sTagNameLower, $aHtmlAllowedTags)))
|
||||||
|
{
|
||||||
|
$aRemove[] = @$oElement;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -913,6 +920,7 @@ class HtmlUtils
|
||||||
$aNodes = $oDom->getElementsByTagName('*');
|
$aNodes = $oDom->getElementsByTagName('*');
|
||||||
foreach ($aNodes as /* @var $oElement \DOMElement */ $oElement)
|
foreach ($aNodes as /* @var $oElement \DOMElement */ $oElement)
|
||||||
{
|
{
|
||||||
|
$aRemovedAttrs = array();
|
||||||
$sTagNameLower = \strtolower($oElement->tagName);
|
$sTagNameLower = \strtolower($oElement->tagName);
|
||||||
|
|
||||||
// convert body attributes to styles
|
// convert body attributes to styles
|
||||||
|
@ -929,14 +937,14 @@ class HtmlUtils
|
||||||
|
|
||||||
if (isset($oElement->attributes))
|
if (isset($oElement->attributes))
|
||||||
{
|
{
|
||||||
foreach ($oElement->attributes as $sAttributeName => /* @var $oAttributeNode \DOMNode */ $oAttributeNode)
|
foreach ($oElement->attributes as $sAttrName => /* @var $oAttributeNode \DOMNode */ $oAttributeNode)
|
||||||
{
|
{
|
||||||
if ($oAttributeNode && isset($oAttributeNode->nodeValue))
|
if ($oAttributeNode && isset($oAttributeNode->nodeValue))
|
||||||
{
|
{
|
||||||
$sAttributeNameLower = \strtolower($sAttributeName);
|
$sAttrNameLower = \trim(\strtolower($sAttrName));
|
||||||
if (isset($aAttrs[$sAttributeNameLower]) && '' === $aAttrs[$sAttributeNameLower])
|
if (isset($aAttrs[$sAttrNameLower]) && '' === $aAttrs[$sAttrNameLower])
|
||||||
{
|
{
|
||||||
$aAttrs[$sAttributeNameLower] = array($sAttributeName, \trim($oAttributeNode->nodeValue));
|
$aAttrs[$sAttrNameLower] = array($sAttrName, \trim($oAttributeNode->nodeValue));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -998,33 +1006,43 @@ class HtmlUtils
|
||||||
'color: '.$sLinkColor.\trim((empty($sStyles) ? '' : '; '.$sStyles)));
|
'color: '.$sLinkColor.\trim((empty($sStyles) ? '' : '; '.$sStyles)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (\in_array($sTagNameLower, array('a', 'form', 'area')))
|
|
||||||
{
|
|
||||||
$oElement->setAttribute('target', '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (\in_array($sTagNameLower, array('a', 'form', 'area', 'input', 'button', 'textarea')))
|
|
||||||
{
|
|
||||||
$oElement->setAttribute('tabindex', '-1');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($oElement->hasAttributes() && isset($oElement->attributes) && $oElement->attributes)
|
if ($oElement->hasAttributes() && isset($oElement->attributes) && $oElement->attributes)
|
||||||
{
|
{
|
||||||
foreach ($oElement->attributes as $oAttr)
|
$aHtmlAllowedAttributes = isset(\MailSo\Config::$HtmlStrictAllowedAttributes) &&
|
||||||
|
\is_array(\MailSo\Config::$HtmlStrictAllowedAttributes) && 0 < \count(\MailSo\Config::$HtmlStrictAllowedAttributes) ?
|
||||||
|
\MailSo\Config::$HtmlStrictAllowedAttributes : null;
|
||||||
|
|
||||||
|
$sAttrsForRemove = array();
|
||||||
|
foreach ($oElement->attributes as $sAttrName => $oAttr)
|
||||||
{
|
{
|
||||||
if ($oAttr && !empty($oAttr->nodeName))
|
if ($sAttrName && $oAttr)
|
||||||
{
|
{
|
||||||
$sAttrName = \trim(\strtolower($oAttr->nodeName));
|
$sAttrNameLower = \trim(\strtolower($sAttrName));
|
||||||
if ('on' === \substr($sAttrName, 0, 2) || in_array($sAttrName, array(
|
if ($aHtmlAllowedAttributes && !\in_array($sAttrNameLower, $aHtmlAllowedAttributes))
|
||||||
'id', 'class', 'contenteditable', 'designmode', 'formaction', 'manifest',
|
{
|
||||||
|
$sAttrsForRemove[] = $sAttrName;
|
||||||
|
}
|
||||||
|
else if ('on' === \substr($sAttrNameLower, 0, 2) || in_array($sAttrNameLower, array(
|
||||||
|
'id', 'class', 'contenteditable', 'designmode', 'formaction', 'manifest', 'action',
|
||||||
'data-bind', 'data-reactid', 'xmlns', 'srcset', 'data-x-skip-style',
|
'data-bind', 'data-reactid', 'xmlns', 'srcset', 'data-x-skip-style',
|
||||||
'fscommand', 'seeksegmenttime'
|
'fscommand', 'seeksegmenttime'
|
||||||
)))
|
)))
|
||||||
{
|
{
|
||||||
@$oElement->removeAttribute($oAttr->nodeName);
|
$sAttrsForRemove[] = $sAttrName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (0 < \count($sAttrsForRemove))
|
||||||
|
{
|
||||||
|
foreach ($sAttrsForRemove as $sName)
|
||||||
|
{
|
||||||
|
@$oElement->removeAttribute($sName);
|
||||||
|
$aRemovedAttrs[\trim(\strtolower($sName))] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($sAttrsForRemove);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($oElement->hasAttribute('href'))
|
if ($oElement->hasAttribute('href'))
|
||||||
|
@ -1038,10 +1056,20 @@ class HtmlUtils
|
||||||
|
|
||||||
if ('a' === $sTagNameLower)
|
if ('a' === $sTagNameLower)
|
||||||
{
|
{
|
||||||
$oElement->setAttribute('rel', 'external nofollow');
|
$oElement->setAttribute('rel', 'external nofollow noopener noreferrer');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (\in_array($sTagNameLower, array('a', 'form', 'area')))
|
||||||
|
{
|
||||||
|
$oElement->setAttribute('target', '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\in_array($sTagNameLower, array('a', 'form', 'area', 'input', 'button', 'textarea')))
|
||||||
|
{
|
||||||
|
$oElement->setAttribute('tabindex', '-1');
|
||||||
|
}
|
||||||
|
|
||||||
if ($bTryToDetectHiddenImages && 'img' === $sTagNameLower)
|
if ($bTryToDetectHiddenImages && 'img' === $sTagNameLower)
|
||||||
{
|
{
|
||||||
$sAlt = $oElement->hasAttribute('alt')
|
$sAlt = $oElement->hasAttribute('alt')
|
||||||
|
@ -1177,6 +1205,17 @@ class HtmlUtils
|
||||||
}
|
}
|
||||||
|
|
||||||
$oElement->removeAttribute('data-x-skip-style');
|
$oElement->removeAttribute('data-x-skip-style');
|
||||||
|
|
||||||
|
if (\MailSo\Config::$HtmlStrictDebug && 0 < \count($aRemovedAttrs))
|
||||||
|
{
|
||||||
|
unset($aRemovedAttrs['class'], $aRemovedAttrs['target'], $aRemovedAttrs['id'], $aRemovedAttrs['name']);
|
||||||
|
|
||||||
|
$aRemovedAttrs = \array_keys($aRemovedAttrs);
|
||||||
|
if (0 < \count($aRemovedAttrs))
|
||||||
|
{
|
||||||
|
$oElement->setAttribute('data-removed-attrs', \implode(',', $aRemovedAttrs));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$sResult = \MailSo\Base\HtmlUtils::GetTextFromDom($oDom, $bWrapByFakeHtmlAndBodyDiv);
|
$sResult = \MailSo\Base\HtmlUtils::GetTextFromDom($oDom, $bWrapByFakeHtmlAndBodyDiv);
|
||||||
|
@ -1251,16 +1290,6 @@ class HtmlUtils
|
||||||
$oElement->removeAttribute('data-x-href');
|
$oElement->removeAttribute('data-x-href');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($oElement->hasAttribute('data-x-additional-src'))
|
|
||||||
{
|
|
||||||
$oElement->removeAttribute('data-x-additional-src');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($oElement->hasAttribute('data-x-additional-style-url'))
|
|
||||||
{
|
|
||||||
$oElement->removeAttribute('data-x-additional-style-url');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($oElement->hasAttribute('data-x-style-cid-name') && $oElement->hasAttribute('data-x-style-cid'))
|
if ($oElement->hasAttribute('data-x-style-cid-name') && $oElement->hasAttribute('data-x-style-cid'))
|
||||||
{
|
{
|
||||||
$sCidName = $oElement->getAttribute('data-x-style-cid-name');
|
$sCidName = $oElement->getAttribute('data-x-style-cid-name');
|
||||||
|
@ -1290,14 +1319,15 @@ class HtmlUtils
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($oElement->hasAttribute('data-original'))
|
foreach (array(
|
||||||
|
'data-x-additional-src', 'data-x-additional-style-url', 'data-removed-attrs',
|
||||||
|
'data-original', 'data-x-div-type', 'data-wrp', 'data-bind'
|
||||||
|
) as $sName)
|
||||||
{
|
{
|
||||||
$oElement->removeAttribute('data-original');
|
if ($oElement->hasAttribute($sName))
|
||||||
}
|
{
|
||||||
|
$oElement->removeAttribute($sName);
|
||||||
if ($oElement->hasAttribute('data-x-div-type'))
|
}
|
||||||
{
|
|
||||||
$oElement->removeAttribute('data-x-div-type');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($oElement->hasAttribute('data-x-style-url'))
|
if ($oElement->hasAttribute('data-x-style-url'))
|
||||||
|
|
|
@ -26,6 +26,21 @@ class Config
|
||||||
*/
|
*/
|
||||||
public static $MBSTRING = true;
|
public static $MBSTRING = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array|null
|
||||||
|
*/
|
||||||
|
public static $HtmlStrictAllowedTags = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array|null
|
||||||
|
*/
|
||||||
|
public static $HtmlStrictAllowedAttributes = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var boolean
|
||||||
|
*/
|
||||||
|
public static $HtmlStrictDebug = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var bool
|
* @var bool
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -9968,24 +9968,6 @@ NewThemeLink IncludeCss LoadingDescriptionEsc TemplatesLink LangLink IncludeBack
|
||||||
$mResult['InReplyTo'] = $mResponse->InReplyTo();
|
$mResult['InReplyTo'] = $mResponse->InReplyTo();
|
||||||
$mResult['References'] = $mResponse->References();
|
$mResult['References'] = $mResponse->References();
|
||||||
|
|
||||||
$fAdditionalDomReader = null;
|
|
||||||
if (0 < \strlen($sHtml) && $this->Config()->Get('labs', 'emogrifier', false))
|
|
||||||
{
|
|
||||||
if (!\class_exists('RainLoopVendor\Pelago\Emogrifier', false))
|
|
||||||
{
|
|
||||||
include_once APP_VERSION_ROOT_PATH.'app/libraries/emogrifier/Emogrifier.php';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (\class_exists('RainLoopVendor\Pelago\Emogrifier', false))
|
|
||||||
{
|
|
||||||
$fAdditionalDomReader = function ($oDom) {
|
|
||||||
$oEmogrifier = new \RainLoopVendor\Pelago\Emogrifier();
|
|
||||||
$oEmogrifier->preserveEncoding = false;
|
|
||||||
return $oEmogrifier->emogrify($oDom);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fAdditionalExternalFilter = null;
|
$fAdditionalExternalFilter = null;
|
||||||
if (!!$this->Config()->Get('labs', 'use_local_proxy_for_external_images', false))
|
if (!!$this->Config()->Get('labs', 'use_local_proxy_for_external_images', false))
|
||||||
{
|
{
|
||||||
|
@ -10004,8 +9986,7 @@ NewThemeLink IncludeCss LoadingDescriptionEsc TemplatesLink LangLink IncludeBack
|
||||||
|
|
||||||
$mResult['Html'] = 0 === \strlen($sHtml) ? '' : \MailSo\Base\HtmlUtils::ClearHtml(
|
$mResult['Html'] = 0 === \strlen($sHtml) ? '' : \MailSo\Base\HtmlUtils::ClearHtml(
|
||||||
$sHtml, $bHasExternals, $mFoundedCIDs, $aContentLocationUrls, $mFoundedContentLocationUrls, false, false,
|
$sHtml, $bHasExternals, $mFoundedCIDs, $aContentLocationUrls, $mFoundedContentLocationUrls, false, false,
|
||||||
$fAdditionalExternalFilter, $fAdditionalDomReader,
|
$fAdditionalExternalFilter, null, !!$this->Config()->Get('labs', 'try_to_detect_hidden_images', false)
|
||||||
!!$this->Config()->Get('labs', 'try_to_detect_hidden_images', false)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$mResult['ExternalProxy'] = null !== $fAdditionalExternalFilter;
|
$mResult['ExternalProxy'] = null !== $fAdditionalExternalFilter;
|
||||||
|
|
|
@ -134,6 +134,52 @@ class Api
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
\MailSo\Config::$HtmlStrictDebug = !!\RainLoop\Api::Config()->Get('debug', 'enable', false);
|
||||||
|
|
||||||
|
if (\RainLoop\Api::Config()->Get('labs', 'strict_html_parser', true))
|
||||||
|
{
|
||||||
|
\MailSo\Config::$HtmlStrictAllowedAttributes = array(
|
||||||
|
// rainloop
|
||||||
|
'data-wrp',
|
||||||
|
// defaults
|
||||||
|
'name',
|
||||||
|
'dir', 'lang', 'style', 'title',
|
||||||
|
'background', 'bgcolor', 'alt', 'height', 'width', 'src', 'href',
|
||||||
|
'border', 'bordercolor', 'charset', 'direction', 'language',
|
||||||
|
// a
|
||||||
|
'coords', 'download', 'hreflang', 'shape',
|
||||||
|
// body
|
||||||
|
'alink', 'bgproperties', 'bottommargin', 'leftmargin', 'link', 'rightmargin', 'text', 'topmargin', 'vlink',
|
||||||
|
'marginwidth', 'marginheight', 'offset',
|
||||||
|
// button,
|
||||||
|
'disabled', 'type', 'value',
|
||||||
|
// col
|
||||||
|
'align', 'valign',
|
||||||
|
// font
|
||||||
|
'color', 'face', 'size',
|
||||||
|
// form
|
||||||
|
'novalidate',
|
||||||
|
// hr
|
||||||
|
'noshade',
|
||||||
|
// img
|
||||||
|
'hspace', 'sizes', 'srcset', 'vspace', 'usemap',
|
||||||
|
// input, textarea
|
||||||
|
'checked', 'max', 'min', 'maxlength', 'multiple', 'pattern', 'placeholder', 'readonly', 'required', 'step', 'wrap',
|
||||||
|
// label
|
||||||
|
'for',
|
||||||
|
// meter
|
||||||
|
'low', 'high', 'optimum',
|
||||||
|
// ol
|
||||||
|
'reversed', 'start',
|
||||||
|
// option
|
||||||
|
'selected', 'label',
|
||||||
|
// table
|
||||||
|
'cols', 'rows', 'frame', 'rules', 'summary', 'cellpadding', 'cellspacing',
|
||||||
|
// td
|
||||||
|
'abbr', 'axis', 'colspan', 'rowspan', 'headers', 'nowrap'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -446,8 +446,8 @@ Enables caching in the system'),
|
||||||
'cookie_default_secure' => array(false),
|
'cookie_default_secure' => array(false),
|
||||||
'replace_env_in_configuration' => array(''),
|
'replace_env_in_configuration' => array(''),
|
||||||
'startup_url' => array(''),
|
'startup_url' => array(''),
|
||||||
'emogrifier' => array(true),
|
|
||||||
'nice_social_redirect' => array(true),
|
'nice_social_redirect' => array(true),
|
||||||
|
'strict_html_parser' => array(false),
|
||||||
'dev_email' => array(''),
|
'dev_email' => array(''),
|
||||||
'dev_password' => array('')
|
'dev_password' => array('')
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
2012-05-01
|
|
||||||
Made removal of invisible nodes operate in a case-insensitive manner... Thanks Juha P.!
|
|
||||||
|
|
||||||
2012-02-07
|
|
||||||
Fixed some recent code introductions to use class constants rather than global constants.
|
|
||||||
Fixed some recent code introductions to make it cleaner to read.
|
|
||||||
|
|
||||||
2012-01-31
|
|
||||||
Fixed a bug that was introduced with the 2011-12-22 revision... Thanks Sagi L. and M. Bąkowski!
|
|
||||||
Added extraction of <style> blocks within the HTML due to popular demand.
|
|
||||||
Added several new pseudo-selectors (first-child, last-child, nth-child, and nth-of-type).
|
|
||||||
|
|
||||||
2011-12-22
|
|
||||||
Fixed a bug that was overwriting existing inline styles from the original HTML... Thanks Sagi L.!
|
|
||||||
|
|
||||||
2011-10-26
|
|
||||||
Added an option to allow you to output emogrified code without extended characters being turned into HTML entities.
|
|
||||||
Moved static references to class attributes so they can be manipulated.
|
|
||||||
Added the ability to clear out the (formerly) static cache when CSS is reloaded.
|
|
||||||
|
|
||||||
2011-10-13
|
|
||||||
Fully fixed a bug introduced in 2011-06-08 where selectors at the beginning of the CSS would be parsed incorrectly... Thanks Thomas A.!
|
|
||||||
|
|
||||||
2011-08-03
|
|
||||||
Fixed an error where an empty selector at the beginning of the CSS would cause a parse error on the next selector... Thanks Alexei T.!
|
|
||||||
|
|
||||||
2011-06-08
|
|
||||||
Fixed an error where CSS @media types weren't being parsed correctly... Thanks Will W.!
|
|
||||||
|
|
||||||
2011-04-08
|
|
||||||
Fixed errors in CSS->XPath conversion for adjacent sibling selectors and id/class combinations... Thanks Bob V.!
|
|
||||||
|
|
||||||
2010-09-03
|
|
||||||
Added checks to invisible node removal to ensure that we don't try to remove non-existent child nodes of parents that have already been deleted
|
|
||||||
|
|
||||||
2010-07-26
|
|
||||||
Fixed bug where '0' values were getting discarded because of php's empty() function... Thanks Scott!
|
|
||||||
|
|
||||||
2010-06-16
|
|
||||||
Added static caching for less processing overhead in situations where multiple emogrification takes place
|
|
||||||
|
|
||||||
2010-05-18
|
|
||||||
Fixed bug where full url filenames with protocols wouldn't get split improperly when we explode on ':'... Thanks Mark!
|
|
||||||
Added two new attribute selectors
|
|
||||||
|
|
||||||
2009-11-04
|
|
||||||
Explicitly declared static functions static to get rid of E_STRICT notices.
|
|
||||||
|
|
||||||
2009-10-29
|
|
||||||
Fixed so that selectors appearing later in the CSS will have precedence over identical selectors appearing earlier.
|
|
||||||
|
|
||||||
2009-08-17
|
|
||||||
Fixed CSS selector processing so that selectors are processed by precedence/specificity, and not just in order.
|
|
||||||
|
|
||||||
2009-08-13
|
|
||||||
Added support for subset class values (e.g. "p.class1.class2"). Added better protection for bad css attributes.
|
|
||||||
Fixed support for HTML entities.
|
|
||||||
|
|
||||||
2009-06-03
|
|
||||||
Normalize existing CSS (style) attributes in the HTML before we process the CSS.
|
|
||||||
Made it so that the display:none stripper doesn't require a trailing semi-colon.
|
|
||||||
|
|
||||||
2008-03-02
|
|
||||||
Added licensing terms under the MIT License; Only remove unprocessable HTML tags if they exist in the array
|
|
||||||
|
|
||||||
2008-10-20
|
|
||||||
Fixed bug with bad variable name... Thanks Thomas!
|
|
||||||
|
|
||||||
2008-08-18
|
|
||||||
Added lines instructing DOMDocument to attempt to normalize HTML before processing
|
|
||||||
|
|
||||||
2008-08-10
|
|
||||||
Fixed CSS comment stripping regex to add PCRE_DOTALL (changed from '/\/\*.*\*\//U' to '/\/\*.*\*\//sU')
|
|
|
@ -1,981 +0,0 @@
|
||||||
<?php
|
|
||||||
namespace RainLoopVendor\Pelago;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class provides functions for converting CSS styles into inline style attributes in your HTML code.
|
|
||||||
*
|
|
||||||
* For more information, please see the README.md file.
|
|
||||||
*
|
|
||||||
* @author Cameron Brooks
|
|
||||||
* @author Jaime Prado
|
|
||||||
* @author Roman Ožana <ozana@omdesign.cz>
|
|
||||||
*/
|
|
||||||
class Emogrifier
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
const ENCODING = 'UTF-8';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
const CACHE_KEY_CSS = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
const CACHE_KEY_SELECTOR = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
const CACHE_KEY_XPATH = 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
const CACHE_KEY_CSS_DECLARATION_BLOCK = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* for calculating nth-of-type and nth-child selectors
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
const INDEX = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* for calculating nth-of-type and nth-child selectors
|
|
||||||
*
|
|
||||||
* @var int
|
|
||||||
*/
|
|
||||||
const MULTIPLIER = 1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
const ID_ATTRIBUTE_MATCHER = '/(\\w+)?\\#([\\w\\-]+)/';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
private $html = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
private $css = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string[]
|
|
||||||
*/
|
|
||||||
private $unprocessableHtmlTags = array('wbr');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array[]
|
|
||||||
*/
|
|
||||||
private $caches = array(
|
|
||||||
self::CACHE_KEY_CSS => array(),
|
|
||||||
self::CACHE_KEY_SELECTOR => array(),
|
|
||||||
self::CACHE_KEY_XPATH => array(),
|
|
||||||
self::CACHE_KEY_CSS_DECLARATION_BLOCK => array(),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the visited nodes with the XPath paths as array keys
|
|
||||||
*
|
|
||||||
* @var \DOMNode[]
|
|
||||||
*/
|
|
||||||
private $visitedNodes = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* the styles to apply to the nodes with the XPath paths as array keys for the outer array
|
|
||||||
* and the attribute names/values as key/value pairs for the inner array
|
|
||||||
*
|
|
||||||
* @var array[]
|
|
||||||
*/
|
|
||||||
private $styleAttributesForNodes = array();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
|
|
||||||
* If set to false, the value of the style attributes will be discarded.
|
|
||||||
*
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
private $isInlineStyleAttributesParsingEnabled = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether the <style> blocks in the HTML passed to this class should be parsed.
|
|
||||||
*
|
|
||||||
* If set to true, the <style> blocks will be removed from the HTML and their contents will be applied to the HTML
|
|
||||||
* via inline styles.
|
|
||||||
*
|
|
||||||
* If set to false, the <style> blocks will be left as they are in the HTML.
|
|
||||||
*
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
private $isStyleBlocksParsingEnabled = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This attribute applies to the case where you want to preserve your original text encoding.
|
|
||||||
*
|
|
||||||
* By default, emogrifier translates your text into HTML entities for two reasons:
|
|
||||||
*
|
|
||||||
* 1. Because of client incompatibilities, it is better practice to send out HTML entities
|
|
||||||
* rather than unicode over email.
|
|
||||||
*
|
|
||||||
* 2. It translates any illegal XML characters that DOMDocument cannot work with.
|
|
||||||
*
|
|
||||||
* If you would like to preserve your original encoding, set this attribute to true.
|
|
||||||
*
|
|
||||||
* @var bool
|
|
||||||
*/
|
|
||||||
public $preserveEncoding = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The constructor.
|
|
||||||
*
|
|
||||||
* @param string $html the HTML to emogrify, must be UTF-8-encoded
|
|
||||||
* @param string $css the CSS to merge, must be UTF-8-encoded
|
|
||||||
*/
|
|
||||||
public function __construct($html = '', $css = '')
|
|
||||||
{
|
|
||||||
$this->setHtml($html);
|
|
||||||
$this->setCss($css);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The destructor.
|
|
||||||
*/
|
|
||||||
public function __destruct()
|
|
||||||
{
|
|
||||||
$this->purgeVisitedNodes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the HTML to emogrify.
|
|
||||||
*
|
|
||||||
* @param string $html the HTML to emogrify, must be UTF-8-encoded
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function setHtml($html)
|
|
||||||
{
|
|
||||||
$this->html = $html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the CSS to merge with the HTML.
|
|
||||||
*
|
|
||||||
* @param string $css the CSS to merge, must be UTF-8-encoded
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function setCss($css)
|
|
||||||
{
|
|
||||||
$this->css = $css;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies the CSS you submit to the HTML you submit.
|
|
||||||
*
|
|
||||||
* This method places the CSS inline.
|
|
||||||
*
|
|
||||||
* @param \DOMDocument $dom = null
|
|
||||||
*
|
|
||||||
* @return string|\DOMDocument
|
|
||||||
*
|
|
||||||
* @throws \BadMethodCallException
|
|
||||||
*/
|
|
||||||
public function emogrify($dom = null)
|
|
||||||
{
|
|
||||||
if ($this->html === '' && !$dom) {
|
|
||||||
throw new \BadMethodCallException('Please set some HTML first before calling emogrify.', 1390393096);
|
|
||||||
}
|
|
||||||
|
|
||||||
$xmlDocument = $dom ? $dom : $this->createXmlDocument();
|
|
||||||
$xpath = new \DOMXPath($xmlDocument);
|
|
||||||
$this->clearAllCaches();
|
|
||||||
|
|
||||||
// Before be begin processing the CSS file, parse the document and normalize all existing CSS attributes.
|
|
||||||
// This changes 'DISPLAY: none' to 'display: none'.
|
|
||||||
// We wouldn't have to do this if DOMXPath supported XPath 2.0.
|
|
||||||
// Also store a reference of nodes with existing inline styles so we don't overwrite them.
|
|
||||||
$this->purgeVisitedNodes();
|
|
||||||
|
|
||||||
$nodesWithStyleAttributes = @$xpath->query('//*[@style]');
|
|
||||||
if ($nodesWithStyleAttributes) {
|
|
||||||
/** @var \DOMElement $node */
|
|
||||||
foreach ($nodesWithStyleAttributes as $node) {
|
|
||||||
if ($this->isInlineStyleAttributesParsingEnabled) {
|
|
||||||
$this->normalizeStyleAttributes($node);
|
|
||||||
} else {
|
|
||||||
$node->removeAttribute('style');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// grab any existing style blocks from the html and append them to the existing CSS
|
|
||||||
// (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
|
|
||||||
$allCss = $this->css;
|
|
||||||
|
|
||||||
if ($this->isStyleBlocksParsingEnabled) {
|
|
||||||
$allCss .= $this->getCssFromAllStyleNodes($xpath);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cssParts = $this->splitCssAndMediaQuery($allCss);
|
|
||||||
|
|
||||||
$cssKey = md5($cssParts['css']);
|
|
||||||
if (!isset($this->caches[self::CACHE_KEY_CSS][$cssKey])) {
|
|
||||||
// process the CSS file for selectors and definitions
|
|
||||||
preg_match_all('/(?:^|[\\s^{}]*)([^{]+){([^}]*)}/mis', $cssParts['css'], $matches, PREG_SET_ORDER);
|
|
||||||
|
|
||||||
$allSelectors = array();
|
|
||||||
foreach ($matches as $key => $selectorString) {
|
|
||||||
// if there is a blank definition, skip
|
|
||||||
if (!strlen(trim($selectorString[2]))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// else split by commas and duplicate attributes so we can sort by selector precedence
|
|
||||||
$selectors = explode(',', $selectorString[1]);
|
|
||||||
foreach ($selectors as $selector) {
|
|
||||||
// don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
|
|
||||||
// only allow structural pseudo-classes
|
|
||||||
if (strpos($selector, ':') !== false && !preg_match('/:\\S+\\-(child|type)\\(/i', $selector)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$allSelectors[] = array('selector' => trim($selector),
|
|
||||||
'attributes' => trim($selectorString[2]),
|
|
||||||
// keep track of where it appears in the file, since order is important
|
|
||||||
'line' => $key,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// now sort the selectors by precedence
|
|
||||||
usort($allSelectors, array($this,'sortBySelectorPrecedence'));
|
|
||||||
|
|
||||||
$this->caches[self::CACHE_KEY_CSS][$cssKey] = $allSelectors;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->caches[self::CACHE_KEY_CSS][$cssKey] as $value) {
|
|
||||||
// query the body for the xpath selector
|
|
||||||
$nodesMatchingCssSelectors = @$xpath->query($this->translateCssToXpath($value['selector']));
|
|
||||||
if (!$nodesMatchingCssSelectors)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var \DOMElement $node */
|
|
||||||
foreach ($nodesMatchingCssSelectors as $node) {
|
|
||||||
// if it has a style attribute, get it, process it, and append (overwrite) new stuff
|
|
||||||
if ($node->hasAttribute('style')) {
|
|
||||||
// break it up into an associative array
|
|
||||||
$oldStyleDeclarations = $this->parseCssDeclarationBlock($node->getAttribute('style'));
|
|
||||||
} else {
|
|
||||||
$oldStyleDeclarations = array();
|
|
||||||
}
|
|
||||||
$newStyleDeclarations = $this->parseCssDeclarationBlock($value['attributes']);
|
|
||||||
$node->setAttribute(
|
|
||||||
'style',
|
|
||||||
$this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isInlineStyleAttributesParsingEnabled) {
|
|
||||||
$this->fillStyleAttributesWithMergedStyles();
|
|
||||||
}
|
|
||||||
|
|
||||||
// This removes styles from your email that contain display:none.
|
|
||||||
// We need to look for display:none, but we need to do a case-insensitive search. Since DOMDocument only
|
|
||||||
// supports XPath 1.0, lower-case() isn't available to us. We've thus far only set attributes to lowercase,
|
|
||||||
// not attribute values. Consequently, we need to translate() the letters that would be in 'NONE' ("NOE")
|
|
||||||
// to lowercase.
|
|
||||||
$nodesWithStyleDisplayNone = @$xpath->query(
|
|
||||||
'//*[contains(translate(translate(@style," ",""),"NOE","noe"),"display:none")]'
|
|
||||||
);
|
|
||||||
// The checks on parentNode and is_callable below ensure that if we've deleted the parent node,
|
|
||||||
// we don't try to call removeChild on a nonexistent child node
|
|
||||||
if ($nodesWithStyleDisplayNone && $nodesWithStyleDisplayNone->length > 0) {
|
|
||||||
/** @var \DOMNode $node */
|
|
||||||
foreach ($nodesWithStyleDisplayNone as $node) {
|
|
||||||
if ($node->parentNode && is_callable(array($node->parentNode,'removeChild'))) {
|
|
||||||
$node->parentNode->removeChild($node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->copyCssWithMediaToStyleNode($cssParts, $xmlDocument);
|
|
||||||
|
|
||||||
if ($dom)
|
|
||||||
{
|
|
||||||
return $xmlDocument;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->preserveEncoding) {
|
|
||||||
return mb_convert_encoding($xmlDocument->saveHTML(), self::ENCODING, 'HTML-ENTITIES');
|
|
||||||
} else {
|
|
||||||
return $xmlDocument->saveHTML();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disables the parsing of inline styles.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function disableInlineStyleAttributesParsing()
|
|
||||||
{
|
|
||||||
$this->isInlineStyleAttributesParsingEnabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disables the parsing of <style> blocks.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function disableStyleBlocksParsing()
|
|
||||||
{
|
|
||||||
$this->isStyleBlocksParsingEnabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all caches.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function clearAllCaches()
|
|
||||||
{
|
|
||||||
$this->clearCache(self::CACHE_KEY_CSS);
|
|
||||||
$this->clearCache(self::CACHE_KEY_SELECTOR);
|
|
||||||
$this->clearCache(self::CACHE_KEY_XPATH);
|
|
||||||
$this->clearCache(self::CACHE_KEY_CSS_DECLARATION_BLOCK);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears a single cache by key.
|
|
||||||
*
|
|
||||||
* @param int $key the cache key, must be CACHE_KEY_CSS, CACHE_KEY_SELECTOR, CACHE_KEY_XPATH
|
|
||||||
* or CACHE_KEY_CSS_DECLARATION_BLOCK
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*
|
|
||||||
* @throws \InvalidArgumentException
|
|
||||||
*/
|
|
||||||
private function clearCache($key)
|
|
||||||
{
|
|
||||||
$allowedCacheKeys = array(
|
|
||||||
self::CACHE_KEY_CSS,
|
|
||||||
self::CACHE_KEY_SELECTOR,
|
|
||||||
self::CACHE_KEY_XPATH,
|
|
||||||
self::CACHE_KEY_CSS_DECLARATION_BLOCK,
|
|
||||||
);
|
|
||||||
if (!in_array($key, $allowedCacheKeys, true)) {
|
|
||||||
throw new \InvalidArgumentException('Invalid cache key: ' . $key, 1391822035);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->caches[$key] = array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Purges the visited nodes.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function purgeVisitedNodes()
|
|
||||||
{
|
|
||||||
$this->visitedNodes = array();
|
|
||||||
$this->styleAttributesForNodes = array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks a tag for removal.
|
|
||||||
*
|
|
||||||
* There are some HTML tags that DOMDocument cannot process, and it will throw an error if it encounters them.
|
|
||||||
* In particular, DOMDocument will complain if you try to use HTML5 tags in an XHTML document.
|
|
||||||
*
|
|
||||||
* Note: The tags will not be removed if they have any content.
|
|
||||||
*
|
|
||||||
* @param string $tagName the tag name, e.g., "p"
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function addUnprocessableHtmlTag($tagName)
|
|
||||||
{
|
|
||||||
$this->unprocessableHtmlTags[] = $tagName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drops a tag from the removal list.
|
|
||||||
*
|
|
||||||
* @param string $tagName the tag name, e.g., "p"
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function removeUnprocessableHtmlTag($tagName)
|
|
||||||
{
|
|
||||||
$key = array_search($tagName, $this->unprocessableHtmlTags, true);
|
|
||||||
if ($key !== false) {
|
|
||||||
unset($this->unprocessableHtmlTags[$key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes the value of the "style" attribute and saves it.
|
|
||||||
*
|
|
||||||
* @param \DOMElement $node
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function normalizeStyleAttributes(\DOMElement $node)
|
|
||||||
{
|
|
||||||
$normalizedOriginalStyle = preg_replace_callback(
|
|
||||||
'/[A-z\\-]+(?=\\:)/S',
|
|
||||||
function (array $m) {
|
|
||||||
return strtolower($m[0]);
|
|
||||||
},
|
|
||||||
$node->getAttribute('style')
|
|
||||||
);
|
|
||||||
|
|
||||||
// in order to not overwrite existing style attributes in the HTML, we
|
|
||||||
// have to save the original HTML styles
|
|
||||||
$nodePath = $node->getNodePath();
|
|
||||||
if (!isset($this->styleAttributesForNodes[$nodePath])) {
|
|
||||||
$this->styleAttributesForNodes[$nodePath] = $this->parseCssDeclarationBlock($normalizedOriginalStyle);
|
|
||||||
$this->visitedNodes[$nodePath] = $node;
|
|
||||||
}
|
|
||||||
|
|
||||||
$node->setAttribute('style', $normalizedOriginalStyle);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges styles from styles attributes and style nodes and applies them to the attribute nodes
|
|
||||||
*
|
|
||||||
* return @void
|
|
||||||
*/
|
|
||||||
private function fillStyleAttributesWithMergedStyles()
|
|
||||||
{
|
|
||||||
foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
|
|
||||||
$node = $this->visitedNodes[$nodePath];
|
|
||||||
$currentStyleAttributes = $this->parseCssDeclarationBlock($node->getAttribute('style'));
|
|
||||||
$node->setAttribute(
|
|
||||||
'style',
|
|
||||||
$this->generateStyleStringFromDeclarationsArrays(
|
|
||||||
$currentStyleAttributes,
|
|
||||||
$styleAttributesForNode
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This method merges old or existing name/value array with new name/value array
|
|
||||||
* and then generates a string of the combined style suitable for placing inline.
|
|
||||||
* This becomes the single point for CSS string generation allowing for consistent
|
|
||||||
* CSS output no matter where the CSS originally came from.
|
|
||||||
*
|
|
||||||
* @param string[] $oldStyles
|
|
||||||
* @param string[] $newStyles
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles)
|
|
||||||
{
|
|
||||||
$combinedStyles = array_merge($oldStyles, $newStyles);
|
|
||||||
$style = '';
|
|
||||||
foreach ($combinedStyles as $attributeName => $attributeValue) {
|
|
||||||
$style .= (strtolower(trim($attributeName)) . ': ' . trim($attributeValue) . '; ');
|
|
||||||
}
|
|
||||||
return trim($style);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies the media part from CSS array parts to $xmlDocument.
|
|
||||||
*
|
|
||||||
* @param string[] $cssParts
|
|
||||||
* @param \DOMDocument $xmlDocument
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function copyCssWithMediaToStyleNode(array $cssParts, \DOMDocument $xmlDocument)
|
|
||||||
{
|
|
||||||
if (isset($cssParts['media']) && $cssParts['media'] !== '') {
|
|
||||||
$this->addStyleElementToDocument($xmlDocument, $cssParts['media']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns CSS content.
|
|
||||||
*
|
|
||||||
* @param \DOMXPath $xpath
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function getCssFromAllStyleNodes(\DOMXPath $xpath)
|
|
||||||
{
|
|
||||||
$styleNodes = @$xpath->query('//style');
|
|
||||||
|
|
||||||
if (!$styleNodes) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$css = '';
|
|
||||||
/** @var \DOMNode $styleNode */
|
|
||||||
foreach ($styleNodes as $styleNode) {
|
|
||||||
$css .= "\n\n" . $styleNode->nodeValue;
|
|
||||||
$styleNode->parentNode->removeChild($styleNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $css;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a style element with $css to $document.
|
|
||||||
*
|
|
||||||
* This method is protected to allow overriding.
|
|
||||||
*
|
|
||||||
* @see https://github.com/jjriv/emogrifier/issues/103
|
|
||||||
*
|
|
||||||
* @param \DOMDocument $document
|
|
||||||
* @param string $css
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
protected function addStyleElementToDocument(\DOMDocument $document, $css)
|
|
||||||
{
|
|
||||||
$styleElement = $document->createElement('style', $css);
|
|
||||||
$styleAttribute = $document->createAttribute('type');
|
|
||||||
$styleAttribute->value = 'text/css';
|
|
||||||
$styleElement->appendChild($styleAttribute);
|
|
||||||
|
|
||||||
$head = $this->getOrCreateHeadElement($document);
|
|
||||||
$head->appendChild($styleElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the existing or creates a new head element in $document.
|
|
||||||
*
|
|
||||||
* @param \DOMDocument $document
|
|
||||||
*
|
|
||||||
* @return \DOMNode the head element
|
|
||||||
*/
|
|
||||||
private function getOrCreateHeadElement(\DOMDocument $document)
|
|
||||||
{
|
|
||||||
$head = $document->getElementsByTagName('head')->item(0);
|
|
||||||
|
|
||||||
if ($head === null) {
|
|
||||||
$head = $document->createElement('head');
|
|
||||||
$html = $document->getElementsByTagName('html')->item(0);
|
|
||||||
$html->insertBefore($head, $document->getElementsByTagName('body')->item(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $head;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits input CSS code to an array where:
|
|
||||||
*
|
|
||||||
* - key "css" will be contains clean CSS code
|
|
||||||
* - key "media" will be contains all valuable media queries
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
*
|
|
||||||
* The CSS code
|
|
||||||
*
|
|
||||||
* "@import "file.css"; h1 { color:red; } @media { h1 {}} @media tv { h1 {}}"
|
|
||||||
*
|
|
||||||
* will be parsed into the following array:
|
|
||||||
*
|
|
||||||
* "css" => "h1 { color:red; }"
|
|
||||||
* "media" => "@media { h1 {}}"
|
|
||||||
*
|
|
||||||
* @param string $css
|
|
||||||
*
|
|
||||||
* @return string[]
|
|
||||||
*/
|
|
||||||
private function splitCssAndMediaQuery($css)
|
|
||||||
{
|
|
||||||
$media = '';
|
|
||||||
|
|
||||||
$css = preg_replace_callback(
|
|
||||||
'#@media\\s+(?:only\\s)?(?:[\\s{\\(]|screen|all)\\s?[^{]+{.*}\\s*}\\s*#misU',
|
|
||||||
function ($matches) use (&$media) {
|
|
||||||
$media .= $matches[0];
|
|
||||||
},
|
|
||||||
$css
|
|
||||||
);
|
|
||||||
|
|
||||||
// filter the CSS
|
|
||||||
$search = array(
|
|
||||||
// get rid of css comment code
|
|
||||||
'/\\/\\*.*\\*\\//sU',
|
|
||||||
// strip out any import directives
|
|
||||||
'/^\\s*@import\\s[^;]+;/misU',
|
|
||||||
// strip remains media enclosures
|
|
||||||
'/^\\s*@media\\s[^{]+{(.*)}\\s*}\\s/misU',
|
|
||||||
);
|
|
||||||
|
|
||||||
$replace = array(
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
|
|
||||||
// clean CSS before output
|
|
||||||
$css = preg_replace($search, $replace, $css);
|
|
||||||
|
|
||||||
return array('css' => $css, 'media' => $media);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a DOMDocument instance with the current HTML.
|
|
||||||
*
|
|
||||||
* @return \DOMDocument
|
|
||||||
*/
|
|
||||||
private function createXmlDocument()
|
|
||||||
{
|
|
||||||
$xmlDocument = new \DOMDocument;
|
|
||||||
$xmlDocument->encoding = self::ENCODING;
|
|
||||||
$xmlDocument->strictErrorChecking = false;
|
|
||||||
$xmlDocument->formatOutput = true;
|
|
||||||
$libXmlState = libxml_use_internal_errors(true);
|
|
||||||
@$xmlDocument->loadHTML($this->getUnifiedHtml());
|
|
||||||
libxml_clear_errors();
|
|
||||||
libxml_use_internal_errors($libXmlState);
|
|
||||||
$xmlDocument->normalizeDocument();
|
|
||||||
|
|
||||||
return $xmlDocument;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the HTML with the non-ASCII characters converts into HTML entities and the unprocessable
|
|
||||||
* HTML tags removed.
|
|
||||||
*
|
|
||||||
* @return string the unified HTML
|
|
||||||
*
|
|
||||||
* @throws \BadMethodCallException
|
|
||||||
*/
|
|
||||||
private function getUnifiedHtml()
|
|
||||||
{
|
|
||||||
if (!empty($this->unprocessableHtmlTags)) {
|
|
||||||
$unprocessableHtmlTags = implode('|', $this->unprocessableHtmlTags);
|
|
||||||
$bodyWithoutUnprocessableTags = preg_replace(
|
|
||||||
'/<\\/?(' . $unprocessableHtmlTags . ')[^>]*>/i',
|
|
||||||
'',
|
|
||||||
$this->html
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$bodyWithoutUnprocessableTags = $this->html;
|
|
||||||
}
|
|
||||||
|
|
||||||
return mb_convert_encoding($bodyWithoutUnprocessableTags, 'HTML-ENTITIES', self::ENCODING);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $a
|
|
||||||
* @param string[] $b
|
|
||||||
*
|
|
||||||
* @return int
|
|
||||||
*/
|
|
||||||
private function sortBySelectorPrecedence(array $a, array $b)
|
|
||||||
{
|
|
||||||
$precedenceA = $this->getCssSelectorPrecedence($a['selector']);
|
|
||||||
$precedenceB = $this->getCssSelectorPrecedence($b['selector']);
|
|
||||||
|
|
||||||
// We want these sorted in ascending order so selectors with lesser precedence get processed first and
|
|
||||||
// selectors with greater precedence get sorted last.
|
|
||||||
$precedenceForEquals = ($a['line'] < $b['line'] ? -1 : 1);
|
|
||||||
$precedenceForNotEquals = ($precedenceA < $precedenceB ? -1 : 1);
|
|
||||||
return ($precedenceA === $precedenceB) ? $precedenceForEquals : $precedenceForNotEquals;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $selector
|
|
||||||
*
|
|
||||||
* @return int
|
|
||||||
*/
|
|
||||||
private function getCssSelectorPrecedence($selector)
|
|
||||||
{
|
|
||||||
$selectorKey = md5($selector);
|
|
||||||
if (!isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
|
|
||||||
$precedence = 0;
|
|
||||||
$value = 100;
|
|
||||||
// ids: worth 100, classes: worth 10, elements: worth 1
|
|
||||||
$search = array('\\#','\\.','');
|
|
||||||
|
|
||||||
foreach ($search as $s) {
|
|
||||||
if (trim($selector) === '') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$number = 0;
|
|
||||||
$selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $number);
|
|
||||||
$precedence += ($value * $number);
|
|
||||||
$value /= 10;
|
|
||||||
}
|
|
||||||
$this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Right now, we support all CSS 1 selectors and most CSS2/3 selectors.
|
|
||||||
*
|
|
||||||
* @see http://plasmasturm.org/log/444/
|
|
||||||
*
|
|
||||||
* @param string $paramCssSelector
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function translateCssToXpath($paramCssSelector)
|
|
||||||
{
|
|
||||||
$cssSelector = ' ' . $paramCssSelector . ' ';
|
|
||||||
$cssSelector = preg_replace_callback(
|
|
||||||
'/\\s+\\w+\\s+/',
|
|
||||||
function (array $matches) {
|
|
||||||
return strtolower($matches[0]);
|
|
||||||
},
|
|
||||||
$cssSelector
|
|
||||||
);
|
|
||||||
$cssSelector = trim($cssSelector);
|
|
||||||
$xpathKey = md5($cssSelector);
|
|
||||||
if (!isset($this->caches[self::CACHE_KEY_XPATH][$xpathKey])) {
|
|
||||||
// returns an Xpath selector
|
|
||||||
$search = array(
|
|
||||||
// Matches any element that is a child of parent.
|
|
||||||
'/\\s+>\\s+/',
|
|
||||||
// Matches any element that is an adjacent sibling.
|
|
||||||
'/\\s+\\+\\s+/',
|
|
||||||
// Matches any element that is a descendant of an parent element element.
|
|
||||||
'/\\s+/',
|
|
||||||
// first-child pseudo-selector
|
|
||||||
'/([^\\/]+):first-child/i',
|
|
||||||
// last-child pseudo-selector
|
|
||||||
'/([^\\/]+):last-child/i',
|
|
||||||
// Matches attribute only selector
|
|
||||||
'/^\\[(\\w+)\\]/',
|
|
||||||
// Matches element with attribute
|
|
||||||
'/(\\w)\\[(\\w+)\\]/',
|
|
||||||
// Matches element with EXACT attribute
|
|
||||||
'/(\\w)\\[(\\w+)\\=[\'"]?(\\w+)[\'"]?\\]/',
|
|
||||||
);
|
|
||||||
$replace = array(
|
|
||||||
'/',
|
|
||||||
'/following-sibling::*[1]/self::',
|
|
||||||
'//',
|
|
||||||
'*[1]/self::\\1',
|
|
||||||
'*[last()]/self::\\1',
|
|
||||||
'*[@\\1]',
|
|
||||||
'\\1[@\\2]',
|
|
||||||
'\\1[@\\2="\\3"]',
|
|
||||||
);
|
|
||||||
|
|
||||||
$cssSelector = '//' . preg_replace($search, $replace, $cssSelector);
|
|
||||||
|
|
||||||
$cssSelector = preg_replace_callback(
|
|
||||||
self::ID_ATTRIBUTE_MATCHER,
|
|
||||||
array($this, 'matchIdAttributes'),
|
|
||||||
$cssSelector
|
|
||||||
);
|
|
||||||
$cssSelector = preg_replace_callback(
|
|
||||||
self::CLASS_ATTRIBUTE_MATCHER,
|
|
||||||
array($this, 'matchClassAttributes'),
|
|
||||||
$cssSelector
|
|
||||||
);
|
|
||||||
|
|
||||||
// Advanced selectors are going to require a bit more advanced emogrification.
|
|
||||||
// When we required PHP 5.3, we could do this with closures.
|
|
||||||
$cssSelector = preg_replace_callback(
|
|
||||||
'/([^\\/]+):nth-child\\(\\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
|
|
||||||
array($this, 'translateNthChild'),
|
|
||||||
$cssSelector
|
|
||||||
);
|
|
||||||
$cssSelector = preg_replace_callback(
|
|
||||||
'/([^\\/]+):nth-of-type\\(\s*(odd|even|[+\\-]?\\d|[+\\-]?\\d?n(\\s*[+\\-]\\s*\\d)?)\\s*\\)/i',
|
|
||||||
array($this, 'translateNthOfType'),
|
|
||||||
$cssSelector
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->caches[self::CACHE_KEY_SELECTOR][$xpathKey] = $cssSelector;
|
|
||||||
}
|
|
||||||
return $this->caches[self::CACHE_KEY_SELECTOR][$xpathKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $match
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function matchIdAttributes(array $match)
|
|
||||||
{
|
|
||||||
return (strlen($match[1]) ? $match[1] : '*') . '[@id="' . $match[2] . '"]';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $match
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function matchClassAttributes(array $match)
|
|
||||||
{
|
|
||||||
return (strlen($match[1]) ? $match[1] : '*') . '[contains(concat(" ",@class," "),concat(" ","' .
|
|
||||||
implode(
|
|
||||||
'"," "))][contains(concat(" ",@class," "),concat(" ","',
|
|
||||||
explode('.', substr($match[2], 1))
|
|
||||||
) . '"," "))]';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $match
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function translateNthChild(array $match)
|
|
||||||
{
|
|
||||||
$result = $this->parseNth($match);
|
|
||||||
|
|
||||||
if (isset($result[self::MULTIPLIER])) {
|
|
||||||
if ($result[self::MULTIPLIER] < 0) {
|
|
||||||
$result[self::MULTIPLIER] = abs($result[self::MULTIPLIER]);
|
|
||||||
return sprintf(
|
|
||||||
'*[(last() - position()) mod %u = %u]/self::%s',
|
|
||||||
$result[self::MULTIPLIER],
|
|
||||||
$result[self::INDEX],
|
|
||||||
$match[1]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return sprintf(
|
|
||||||
'*[position() mod %u = %u]/self::%s',
|
|
||||||
$result[self::MULTIPLIER],
|
|
||||||
$result[self::INDEX],
|
|
||||||
$match[1]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return sprintf('*[%u]/self::%s', $result[self::INDEX], $match[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $match
|
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
|
||||||
private function translateNthOfType(array $match)
|
|
||||||
{
|
|
||||||
$result = $this->parseNth($match);
|
|
||||||
|
|
||||||
if (isset($result[self::MULTIPLIER])) {
|
|
||||||
if ($result[self::MULTIPLIER] < 0) {
|
|
||||||
$result[self::MULTIPLIER] = abs($result[self::MULTIPLIER]);
|
|
||||||
return sprintf(
|
|
||||||
'%s[(last() - position()) mod %u = %u]',
|
|
||||||
$match[1],
|
|
||||||
$result[self::MULTIPLIER],
|
|
||||||
$result[self::INDEX]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return sprintf(
|
|
||||||
'%s[position() mod %u = %u]',
|
|
||||||
$match[1],
|
|
||||||
$result[self::MULTIPLIER],
|
|
||||||
$result[self::INDEX]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return sprintf('%s[%u]', $match[1], $result[self::INDEX]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string[] $match
|
|
||||||
*
|
|
||||||
* @return int[]
|
|
||||||
*/
|
|
||||||
private function parseNth(array $match)
|
|
||||||
{
|
|
||||||
if (in_array(strtolower($match[2]), array('even','odd'), true)) {
|
|
||||||
$index = strtolower($match[2]) === 'even' ? 0 : 1;
|
|
||||||
return array(self::MULTIPLIER => 2, self::INDEX => $index);
|
|
||||||
} elseif (stripos($match[2], 'n') === false) {
|
|
||||||
// if there is a multiplier
|
|
||||||
$index = (int) str_replace(' ', '', $match[2]);
|
|
||||||
return array(self::INDEX => $index);
|
|
||||||
} else {
|
|
||||||
if (isset($match[3])) {
|
|
||||||
$multipleTerm = str_replace($match[3], '', $match[2]);
|
|
||||||
$index = (int) str_replace(' ', '', $match[3]);
|
|
||||||
} else {
|
|
||||||
$multipleTerm = $match[2];
|
|
||||||
$index = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$multiplier = (int) str_ireplace('n', '', $multipleTerm);
|
|
||||||
|
|
||||||
if (!strlen($multiplier)) {
|
|
||||||
$multiplier = 1;
|
|
||||||
} elseif ($multiplier === 0) {
|
|
||||||
return array(self::INDEX => $index);
|
|
||||||
} else {
|
|
||||||
$multiplier = (int) $multiplier;
|
|
||||||
}
|
|
||||||
|
|
||||||
while ($index < 0) {
|
|
||||||
$index += abs($multiplier);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array(self::MULTIPLIER => $multiplier, self::INDEX => $index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a CSS declaration block into property name/value pairs.
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
*
|
|
||||||
* The declaration block
|
|
||||||
*
|
|
||||||
* "color: #000; font-weight: bold;"
|
|
||||||
*
|
|
||||||
* will be parsed into the following array:
|
|
||||||
*
|
|
||||||
* "color" => "#000"
|
|
||||||
* "font-weight" => "bold"
|
|
||||||
*
|
|
||||||
* @param string $cssDeclarationBlock the CSS declaration block without the curly braces, may be empty
|
|
||||||
*
|
|
||||||
* @return string[]
|
|
||||||
* the CSS declarations with the property names as array keys and the property values as array values
|
|
||||||
*/
|
|
||||||
private function parseCssDeclarationBlock($cssDeclarationBlock)
|
|
||||||
{
|
|
||||||
if (isset($this->caches[self::CACHE_KEY_CSS_DECLARATION_BLOCK][$cssDeclarationBlock])) {
|
|
||||||
return $this->caches[self::CACHE_KEY_CSS_DECLARATION_BLOCK][$cssDeclarationBlock];
|
|
||||||
}
|
|
||||||
|
|
||||||
$properties = array();
|
|
||||||
$declarations = explode(';', $cssDeclarationBlock);
|
|
||||||
foreach ($declarations as $declaration) {
|
|
||||||
$matches = array();
|
|
||||||
if (!preg_match('/ *([A-Za-z\\-]+) *: *([^;]+) */', $declaration, $matches)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$propertyName = strtolower($matches[1]);
|
|
||||||
$propertyValue = $matches[2];
|
|
||||||
$properties[$propertyName] = $propertyValue;
|
|
||||||
}
|
|
||||||
$this->caches[self::CACHE_KEY_CSS_DECLARATION_BLOCK][$cssDeclarationBlock] = $properties;
|
|
||||||
|
|
||||||
return $properties;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
Emogrifier is copyright (c) 2008-2014 Pelago and licensed under the MIT license.
|
|
||||||
|
|
||||||
|
|
||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
@ -1,185 +0,0 @@
|
||||||
# Emogrifier
|
|
||||||
|
|
||||||
[![Build Status](https://travis-ci.org/jjriv/emogrifier.svg?branch=master)](https://travis-ci.org/jjriv/emogrifier)
|
|
||||||
[![Latest Stable Version](https://poser.pugx.org/pelago/emogrifier/v/stable.svg)](https://packagist.org/packages/pelago/emogrifier)
|
|
||||||
[![Total Downloads](https://poser.pugx.org/pelago/emogrifier/downloads.svg)](https://packagist.org/packages/pelago/emogrifier)
|
|
||||||
[![Latest Unstable Version](https://poser.pugx.org/pelago/emogrifier/v/unstable.svg)](https://packagist.org/packages/pelago/emogrifier)
|
|
||||||
[![License](https://poser.pugx.org/pelago/emogrifier/license.svg)](https://packagist.org/packages/pelago/emogrifier)
|
|
||||||
|
|
||||||
_n. e•mog•ri•fi•er [\ē-'mä-grƏ-,fī-Ər\] - a utility for changing completely the nature or appearance of HTML email,
|
|
||||||
esp. in a particularly fantastic or bizarre manner_
|
|
||||||
|
|
||||||
Emogrifier converts CSS styles into inline style attributes in your HTML code. This ensures proper display on email
|
|
||||||
and mobile device readers that lack stylesheet support.
|
|
||||||
|
|
||||||
This utility was developed as part of [Intervals](http://www.myintervals.com/) to deal with the problems posed by
|
|
||||||
certain email clients (namely Outlook 2007 and Google Gmail) when it comes to the way they handle styling contained
|
|
||||||
in HTML emails. As many web developers and designers already know, certain email clients are notorious for their
|
|
||||||
lack of CSS support. While attempts are being made to develop common
|
|
||||||
[email standards](http://www.email-standards.org/), implementation is still a ways off.
|
|
||||||
|
|
||||||
The primary problem with uncooperative email clients is that most tend to only regard inline CSS, discarding all
|
|
||||||
`<style>` elements and links to stylesheets in `<link>` elements. Emogrifier solves this problem by converting CSS
|
|
||||||
styles into inline style attributes in your HTML code.
|
|
||||||
|
|
||||||
|
|
||||||
- [How it works](#how-it-works)
|
|
||||||
- [Usage](#usage)
|
|
||||||
- [Installing with Composer](#installing-with-composer)
|
|
||||||
- [Usage](#usage)
|
|
||||||
- [Supported CSS selectors](#supported-css-selectors)
|
|
||||||
- [Caveats](#caveats)
|
|
||||||
- [Maintainer](#maintainer)
|
|
||||||
- [Contributing](#contributing)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## How it Works
|
|
||||||
|
|
||||||
Emogrifier automagically transmogrifies your HTML by parsing your CSS and inserting your CSS definitions into tags
|
|
||||||
within your HTML based on your CSS selectors.
|
|
||||||
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
First, you provide Emogrifier with the HTML and CSS you would like to merge. This can happen directly during
|
|
||||||
instantiation:
|
|
||||||
|
|
||||||
$html = '<html><h1>Hello world!</h1></html>';
|
|
||||||
$css = 'h1 {font-size: 32px;}';
|
|
||||||
$emogrifier = new \Pelago\Emogrifier($html, $css);
|
|
||||||
|
|
||||||
You could also use the setters for providing this data after instantiation:
|
|
||||||
|
|
||||||
$emogrifier = new \Pelago\Emogrifier();
|
|
||||||
|
|
||||||
$html = '<html><h1>Hello world!</h1></html>';
|
|
||||||
$css = 'h1 {font-size: 32px;}';
|
|
||||||
|
|
||||||
$emogrifier->setHtml($html);
|
|
||||||
$emogrifier->setCss($css);
|
|
||||||
|
|
||||||
After you have set the HTML and CSS, you can call the `emogrify` method to merge both:
|
|
||||||
|
|
||||||
$mergedHtml = $emogrifier->emogrify();
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
There are several options that you can set on the Emogrifier object before
|
|
||||||
calling the `emogrify` method:
|
|
||||||
|
|
||||||
* `$emogrifier->disableStyleBlocksParsing()` - By default, Emogrifier will grab
|
|
||||||
all `<style>` blocks in the HTML and will apply the CSS styles as inline
|
|
||||||
"style" attributes to the HTML. The `<style>` blocks will then be removed
|
|
||||||
from the HTML. If you want to disable this functionality so that Emogrifier
|
|
||||||
leaves these `<style>` blocks in the HTML and does not parse them, you should
|
|
||||||
use this option.
|
|
||||||
* `$emogrifier->disableInlineStylesParsing()` - By default, Emogrifier
|
|
||||||
preserves all of the "style" attributes on tags in the HTML you pass to it.
|
|
||||||
However if you want to discard all existing inline styles in the HTML before
|
|
||||||
the CSS is applied, you should use this option.
|
|
||||||
|
|
||||||
|
|
||||||
## Installing with Composer
|
|
||||||
|
|
||||||
Download the [`composer.phar`](https://getcomposer.org/composer.phar) locally or install [Composer](https://getcomposer.org/) globally:
|
|
||||||
|
|
||||||
curl -s https://getcomposer.org/installer | php
|
|
||||||
|
|
||||||
Run the following command for a local installation:
|
|
||||||
|
|
||||||
php composer.phar require pelago/emogrifier:@dev
|
|
||||||
|
|
||||||
Or for a global installation, run the following command:
|
|
||||||
|
|
||||||
composer require pelago/emogrifier:@dev
|
|
||||||
|
|
||||||
You can also add follow lines to your `composer.json` and run the `composer update` command:
|
|
||||||
|
|
||||||
"require": {
|
|
||||||
"pelago/emogrifier": "@dev"
|
|
||||||
}
|
|
||||||
|
|
||||||
See https://getcomposer.org/ for more information and documentation.
|
|
||||||
|
|
||||||
|
|
||||||
## Supported CSS selectors
|
|
||||||
|
|
||||||
Emogrifier currently support the following [CSS selectors](http://www.w3.org/TR/CSS2/selector.html):
|
|
||||||
|
|
||||||
* ID
|
|
||||||
* class
|
|
||||||
* type
|
|
||||||
* descendant
|
|
||||||
* child
|
|
||||||
* adjacent
|
|
||||||
* attribute presence
|
|
||||||
* attribute value
|
|
||||||
* attribute only
|
|
||||||
|
|
||||||
The following selectors are implemented, but currently are broken:
|
|
||||||
|
|
||||||
* first-child (currently broken)
|
|
||||||
* last-child (currently broken)
|
|
||||||
|
|
||||||
The following selectors are not implemented yet:
|
|
||||||
|
|
||||||
* universal
|
|
||||||
|
|
||||||
|
|
||||||
## Caveats
|
|
||||||
|
|
||||||
* Emogrifier requires the HTML and the CSS to be UTF-8. Encodings like ISO8859-1 or ISO8859-15 are not supported.
|
|
||||||
* **NEW:** Emogrifier now preserves all valuable @media queries. Media queries can be very useful in
|
|
||||||
responsive email design. See [media query support](https://litmus.com/help/email-clients/media-query-support/).
|
|
||||||
* **NEW:** Emogrifier will grab existing inline style attributes _and_ will grab `<style>` blocks from your HTML, but it
|
|
||||||
will not grab CSS files referenced in <link> elements (the problem email clients are going to ignore these tags
|
|
||||||
anyway, so why leave them in your HTML?).
|
|
||||||
* Even with styles inline, certain CSS properties are ignored by certain email clients. For more information,
|
|
||||||
refer to these resources:
|
|
||||||
* [http://www.email-standards.org/](http://www.email-standards.org/)
|
|
||||||
* [https://www.campaignmonitor.com/css/](https://www.campaignmonitor.com/css/)
|
|
||||||
* [http://templates.mailchimp.com/resources/email-client-css-support/](http://templates.mailchimp.com/resources/email-client-css-support/)
|
|
||||||
* All CSS attributes that apply to a node will be applied, even if they are redundant. For example, if you define a
|
|
||||||
font attribute _and_ a font-size attribute, both attributes will be applied to that node (in other words, the more
|
|
||||||
specific attribute will not be combined into the more general attribute).
|
|
||||||
* There's a good chance you might encounter problems if your HTML is not well formed and valid (DOMDocument might
|
|
||||||
complain). If you get problems like this, consider running your HTML through [Tidy](http://php.net/manual/en/book.tidy.php)
|
|
||||||
before you pass it to Emogrifier.
|
|
||||||
* Finally, Emogrifier only supports CSS1 level selectors and a few CSS2 level selectors (but not all of them). It
|
|
||||||
does not support pseudo selectors (Emogrifier works by converting CSS selectors to XPath selectors, and pseudo
|
|
||||||
selectors cannot be converted accurately).
|
|
||||||
* `!important` currently is not supported yet.
|
|
||||||
|
|
||||||
|
|
||||||
## Maintainer
|
|
||||||
|
|
||||||
Emogrifier is maintained by the good people at [Pelago](http://www.pelagodesign.com/), info AT pelagodesign DOT com.
|
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Those that wish to contribute bug fixes, new features, refactorings and clean-up to Emogrifier are more than welcome.
|
|
||||||
When you contribute, please take the following things into account:
|
|
||||||
|
|
||||||
* Please cover all changes with unit tests and make sure that your code does not break any existing tests.
|
|
||||||
* To run the existing PHPUnit tests, run the following
|
|
||||||
command:
|
|
||||||
|
|
||||||
composer install
|
|
||||||
|
|
||||||
and then run this command:
|
|
||||||
|
|
||||||
vendor/bin/phpunit Tests/
|
|
||||||
|
|
||||||
If you want to uninstall the development packages that were installed when
|
|
||||||
you ran `composer install`, you can run this command:
|
|
||||||
|
|
||||||
composer install --no-dev
|
|
||||||
* Please use the same coding style (PSR-2) as the rest of the code. Indentation is four spaces.
|
|
||||||
* Please make your code clean, well-readable and easy to understand.
|
|
||||||
* If you add new methods or fields, please use proper PHPDoc for the new methods/fields.
|
|
||||||
* Git commits should have a <= 50 character summary, optionally followed by a blank line
|
|
||||||
and a more in depth description of 79 characters per line.
|
|
||||||
* [Please squash related commits together](http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html).
|
|
||||||
* Please use grammatically correct, complete sentences in the code documentation and the commit messages.
|
|
|
@ -145,9 +145,7 @@
|
||||||
<div class="input-append pull-right" data-bind="visible: allowSearch">
|
<div class="input-append pull-right" data-bind="visible: allowSearch">
|
||||||
<div class="close-input-wrp">
|
<div class="close-input-wrp">
|
||||||
<a class="close" data-bind="click: cancelSearch, visible: '' !== messageListSearchDesc()">×</a>
|
<a class="close" data-bind="click: cancelSearch, visible: '' !== messageListSearchDesc()">×</a>
|
||||||
<input type="search" class="i18n span4 inputSearch" tabindex="-1"
|
<input type="text" class="i18n span4 inputSearch" tabindex="-1" placeholder="Search" autocorrect="off" autocapitalize="off" data-i18n="[placeholder]SEARCH/MAIN_INPUT_PLACEHOLDER" data-bind="value: inputProxyMessageListSearch, onEnter: searchEnterAction, hasfocus: inputMessageListSearchFocus" />
|
||||||
placeholder="Search" autocorrect="off" autocapitalize="off"
|
|
||||||
data-i18n="[placeholder]SEARCH/MAIN_INPUT_PLACEHOLDER" data-bind="value: inputProxyMessageListSearch, onEnter: searchEnterAction, hasfocus: inputMessageListSearchFocus" />
|
|
||||||
</div>
|
</div>
|
||||||
<a class="btn buttonMoreSearch" data-bind="visible: allowSearchAdv, click: advancedSearchClick">
|
<a class="btn buttonMoreSearch" data-bind="visible: allowSearchAdv, click: advancedSearchClick">
|
||||||
<span class="caret"></span>
|
<span class="caret"></span>
|
||||||
|
|
|
@ -69,8 +69,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="position: relative;">
|
<div class="modal-body" style="position: relative;">
|
||||||
<div class="b-list-toolbar">
|
<div class="b-list-toolbar">
|
||||||
<input type="search" class="i18n span3 e-search" placeholder="Search" autocorrect="off" autocapitalize="off"
|
<input type="text" class="i18n span3 e-search" placeholder="Search" autocorrect="off" autocapitalize="off" data-i18n="[placeholder]CONTACTS/SEARCH_INPUT_PLACEHOLDER" data-bind="value: search" />
|
||||||
data-i18n="[placeholder]CONTACTS/SEARCH_INPUT_PLACEHOLDER" data-bind="value: search" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="b-list-content g-ui-user-select-none" data-bind="nano: true, scrollerShadows: true, css: {'hideContactListCheckbox': !useCheckboxesInList()}">
|
<div class="b-list-content g-ui-user-select-none" data-bind="nano: true, scrollerShadows: true, css: {'hideContactListCheckbox': !useCheckboxesInList()}">
|
||||||
<div class="content g-scrollbox" data-scroller-shadows-content>
|
<div class="content g-scrollbox" data-scroller-shadows-content>
|
||||||
|
|
Loading…
Reference in a new issue