You are here

class mimemail_compress in Mime Mail 7

Same name and namespace in other branches
  1. 6 modules/mimemail_compress/mimemail_compress.inc \mimemail_compress

Compresses HTML and CSS into combined message.

Hierarchy

Expanded class hierarchy of mimemail_compress

3 string references to 'mimemail_compress'
MimeMailCompressUnitTestCase::setUp in tests/mimemail_compress.test
Sets up unit test environment.
mimemail_admin_settings in includes/mimemail.admin.inc
Configuration form.
mimemail_compress_mail_post_process in modules/mimemail_compress/mimemail_compress.module
Implements mail_post_process().

File

modules/mimemail_compress/mimemail_compress.inc, line 30
Converts CSS styles into inline style attributes.

View source
class mimemail_compress {
  private $html = '';
  private $css = '';
  private $unprocessable_tags = array(
    'wbr',
  );

  /**
   * Constructor.
   */
  public function __construct($html = '', $css = '') {
    $this->html = $html;
    $this->css = $css;
  }

  /**
   * Adds tag to list of unprocessable tags.
   *
   * There are some HTML tags that DOMDocument cannot process,
   * and will throw an error if it encounters them.
   * It only strips them from the code (does not remove actual nodes).
   */
  public function add_unprocessable_tag($tag) {
    $this->unprocessable_tags[] = $tag;
  }

  /**
   * Removes unprocessable tags from the code (does not remove actual nodes).
   *
   * There are some HTML tags that DOMDocument cannot process,
   * and will throw an error if it encounters them.
   */
  public function remove_unprocessable_tag($tag) {
    if (($key = array_search($tag, $this->unprocessable_tags)) !== FALSE) {
      unset($this->unprocessableHTMLTags[$key]);
    }
  }
  public function compress() {
    if (!class_exists('DOMDocument', FALSE)) {
      return $this->html;
    }
    $body = $this->html;

    // Process the CSS here, turning the CSS style blocks into inline CSS.
    if (count($this->unprocessable_tags)) {
      $unprocessable_tags = implode('|', $this->unprocessable_tags);
      $body = preg_replace("/<({$unprocessable_tags})[^>]*>/i", '', $body);
    }
    $err = error_reporting(0);
    $doc = new DOMDocument();

    // Try to set character encoding.
    if (function_exists('mb_convert_encoding')) {
      $body = mb_convert_encoding($body, 'HTML-ENTITIES', "UTF-8");
      $doc->encoding = "UTF-8";
    }
    $doc->strictErrorChecking = FALSE;
    $doc->formatOutput = TRUE;
    $doc
      ->loadHTML($body);
    $doc
      ->normalizeDocument();
    $xpath = new DOMXPath($doc);

    // Get rid of comments.
    $css = preg_replace('/\\/\\*.*\\*\\//sU', '', $this->css);

    // Process the CSS file for selectors and definitions.
    preg_match_all('/^\\s*([^{]+){([^}]+)}/mis', $css, $matches);
    $all_selectors = array();
    foreach ($matches[1] as $key => $selector_string) {

      // If there is a blank definition, skip.
      if (!strlen(trim($matches[2][$key]))) {
        continue;
      }

      // Else split by commas and duplicate attributes so we can sort by
      // selector precedence.
      $selectors = explode(',', $selector_string);
      foreach ($selectors as $selector) {

        // Don't process pseudo-classes.
        if (strpos($selector, ':') !== FALSE) {
          continue;
        }
        $all_selectors[] = array(
          'selector' => $selector,
          'attributes' => $matches[2][$key],
          // Keep track of where it appears in the file,
          // since order is important.
          'index' => $key,
        );
      }
    }

    // Now sort the selectors by precedence.
    usort($all_selectors, array(
      'self',
      'sort_selector_precedence',
    ));

    // Before we begin processing the CSS file, parse the document for inline
    // styles and append the normalized properties (i.e., 'display: none'
    // instead of 'DISPLAY: none') as selectors with full paths (highest
    // precedence), so they override any file-based selectors.
    $nodes = @$xpath
      ->query('//*[@style]');
    if ($nodes->length > 0) {
      foreach ($nodes as $node) {
        $style = preg_replace_callback('/[A-z\\-]+(?=\\:)/S', array(
          $this,
          'string_to_lower',
        ), $node
          ->getAttribute('style'));
        $all_selectors[] = array(
          'selector' => $this
            ->calculateXPath($node),
          'attributes' => $style,
        );
      }
    }
    foreach ($all_selectors as $value) {

      // Query the body for the xpath selector.
      $nodes = $xpath
        ->query($this
        ->css_to_xpath(trim($value['selector'])));
      foreach ($nodes 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.
          $old_style = $this
            ->css_style_to_array($node
            ->getAttribute('style'));
          $new_style = $this
            ->css_style_to_array($value['attributes']);

          // New styles overwrite the old styles (not technically accurate,
          // but close enough).
          $compressed = array_merge($old_style, $new_style);
          $style = '';
          foreach ($compressed as $k => $v) {
            $style .= drupal_strtolower($k) . ':' . $v . ';';
          }
        }
        else {

          // Otherwise create a new style.
          $style = trim($value['attributes']);
        }
        $node
          ->setAttribute('style', $style);

        // Convert float to align for images.
        $float = preg_match('/float:(left|right)/', $style, $matches);
        if ($node->nodeName == 'img' && $float) {
          $node
            ->setAttribute('align', $matches[1]);
          $node
            ->setAttribute('vspace', 5);
          $node
            ->setAttribute('hspace', 5);
        }
      }
    }

    // This removes styles from your email that contain display:none.
    // You could comment these out if you want.
    $nodes = $xpath
      ->query('//*[contains(translate(@style," ",""), "display:none")]');
    foreach ($nodes as $node) {
      $node->parentNode
        ->removeChild($node);
    }
    if (variable_get('mimemail_preserve_class', 0) == FALSE) {
      $nodes = $xpath
        ->query('//*[@class]');
      foreach ($nodes as $node) {
        $node
          ->removeAttribute('class');
      }
    }
    error_reporting($err);
    return $doc
      ->saveHTML();
  }

  /**
   * Converts a string to lowercase.
   *
   * This is a helper method for the previous method when it is converting
   * strings to lower case. This is being done this way to support PHP versions
   * from 5.2.4 (the minimum version Druapl 7 supports) to PHP 7.2 (the current
   * version at the time of this writing).
   *
   * @param array $matches
   *   The results from a call to preg_replace_callback().
   *
   * @return string
   *   The original string converted to lowercase.
   */
  public function string_to_lower($matches) {
    return strtolower($matches[0]);
  }
  private static function sort_selector_precedence($a, $b) {
    $precedenceA = self::get_selector_precedence($a['selector']);
    $precedenceB = self::get_selector_precedence($b['selector']);

    // We want these sorted ascendingly so selectors with lesser precedence get
    // processed first and selectors with greater precedence get sorted last.
    return $precedenceA == $precedenceB ? $a['index'] < $b['index'] ? -1 : 1 : ($precedenceA < $precedenceB ? -1 : 1);
  }
  private static function get_selector_precedence($selector) {
    $precedence = 0;
    $value = 100;

    // Ids: worth 100, classes: worth 10, elements: worth 1.
    $search = array(
      '\\#',
      '\\.',
      '',
    );
    foreach ($search as $s) {
      if (trim($selector == '')) {
        break;
      }
      $num = 0;
      $selector = preg_replace('/' . $s . '\\w+/', '', $selector, -1, $num);
      $precedence += $value * $num;
      $value /= 10;
    }
    return $precedence;
  }

  /**
   * Replace callback function that matches ID attributes.
   */
  private static function replace_id_attributes($m) {
    return (strlen($m[1]) ? $m[1] : '*') . '[@id="' . $m[2] . '"]';
  }

  /**
   * Replace callback function that matches class attributes.
   */
  private static function replace_class_attributes($m) {
    return (strlen($m[1]) ? $m[1] : '*') . '[contains(concat(" ",normalize-space(@class)," "),concat(" ","' . implode('"," "))][contains(concat(" ",normalize-space(@class)," "),concat(" ","', explode('.', substr($m[2], 1))) . '"," "))]';
  }

  /**
   * Right now we only support CSS 1 selectors, but include CSS2/3 selectors
   * are fully possible.
   *
   * @see http://plasmasturm.org/log/444
   */
  private function css_to_xpath($selector) {
    if (drupal_substr($selector, 0, 1) == '/') {

      // Already an XPath expression.
      return $selector;
    }

    // Returns an Xpath selector.
    $search = array(
      '/\\s+>\\s+/',
      // Matches any F element that is a child of an element E.
      '/(\\w+)\\s+\\+\\s+(\\w+)/',
      // Matches any F element that is a child of an element E.
      '/\\s+/',
      // Matches any F element that is a descendant of an E element.
      '/(\\w)\\[(\\w+)\\]/',
      // Matches element with attribute.
      '/(\\w)\\[(\\w+)\\=[\'"]?(\\w+)[\'"]?\\]/',
    );
    $replace = array(
      '/',
      '\\1/following-sibling::*[1]/self::\\2',
      '//',
      '\\1[@\\2]',
      '\\1[@\\2="\\3"]',
    );
    $result = preg_replace($search, $replace, trim($selector));
    $result = preg_replace_callback('/(\\w+)?\\#([\\w\\-]+)/', 'mimemail_compress::replace_id_attributes', $result);
    $result = preg_replace_callback('/(\\w+|\\*)?((\\.[\\w\\-]+)+)/', 'mimemail_compress::replace_class_attributes', $result);
    return '//' . $result;
  }
  private function css_style_to_array($style) {
    $definitions = explode(';', $style);
    $css_styles = array();
    foreach ($definitions as $def) {
      if (empty($def) || strpos($def, ':') === FALSE) {
        continue;
      }
      list($key, $value) = explode(':', $def, 2);
      if (empty($key) || empty($value)) {
        continue;
      }
      $css_styles[trim($key)] = trim($value);
    }
    return $css_styles;
  }

  /**
   * Get the full path to a DOM node.
   *
   * @param DOMNode $node
   *   The node to analyze.
   *
   * @return string
   *   The full xpath to a DOM node.
   *
   * @see http://stackoverflow.com/questions/2643533/php-getting-xpath-of-a-domnode
   */
  public function calculateXPath(DOMNode $node) {
    $xpath = '';
    $q = new DOMXPath($node->ownerDocument);
    do {
      $position = 1 + $q
        ->query('preceding-sibling::*[name()="' . $node->nodeName . '"]', $node)->length;
      $xpath = '/' . $node->nodeName . '[' . $position . ']' . $xpath;
      $node = $node->parentNode;
    } while (!$node instanceof DOMDocument);
    return $xpath;
  }

}

Members

Namesort descending Modifiers Type Description Overrides
mimemail_compress::$css private property
mimemail_compress::$html private property
mimemail_compress::$unprocessable_tags private property
mimemail_compress::add_unprocessable_tag public function Adds tag to list of unprocessable tags.
mimemail_compress::calculateXPath public function Get the full path to a DOM node.
mimemail_compress::compress public function
mimemail_compress::css_style_to_array private function
mimemail_compress::css_to_xpath private function Right now we only support CSS 1 selectors, but include CSS2/3 selectors are fully possible.
mimemail_compress::get_selector_precedence private static function
mimemail_compress::remove_unprocessable_tag public function Removes unprocessable tags from the code (does not remove actual nodes).
mimemail_compress::replace_class_attributes private static function Replace callback function that matches class attributes.
mimemail_compress::replace_id_attributes private static function Replace callback function that matches ID attributes.
mimemail_compress::sort_selector_precedence private static function
mimemail_compress::string_to_lower public function Converts a string to lowercase.
mimemail_compress::__construct public function Constructor.