Refactor the HTML Corrector.

From: damz <damz@dev.local.local>


---
 CHANGELOG.txt         |    2 +
 filter/filter.module  |   77 +++++++------------------------------------------
 filter/filter.test    |   70 +++++++++++++++++++++++++++++----------------
 system/system.install |   11 +++++++
 4 files changed, 69 insertions(+), 91 deletions(-)

diff --git CHANGELOG.txt CHANGELOG.txt
index 4834cf8..7d45686 100644
--- CHANGELOG.txt
+++ CHANGELOG.txt
@@ -73,6 +73,8 @@ Drupal 7.0, xxxx-xx-xx (development version)
     * If your site is being upgraded from Drupal 6 and you do not have the
       contributed date or event modules installed, user time zone settings will
       fallback to the system time zone and will have to be reconfigured by each user.
+- Filter system:
+    * Refactored the HTML corrector to take advantage of PHP5 features.
 - Removed ping module:
     * Contributed modules with similar functionality are available.
 - Refactored the "access rules" component of user module:
diff --git modules/filter/filter.module modules/filter/filter.module
index 1b36259..81bb6f0 100644
--- modules/filter/filter.module
+++ modules/filter/filter.module
@@ -750,74 +750,19 @@ function _filter_url($text, $format) {
  * Scan input and make sure that all HTML tags are properly closed and nested.
  */
 function _filter_htmlcorrector($text) {
-  // Prepare tag lists.
-  static $no_nesting, $single_use;
-  if (!isset($no_nesting)) {
-    // Tags which cannot be nested but are typically left unclosed.
-    $no_nesting = drupal_map_assoc(array('li', 'p'));
-
-    // Single use tags in HTML4
-    $single_use = drupal_map_assoc(array('base', 'meta', 'link', 'hr', 'br', 'param', 'img', 'area', 'input', 'col', 'frame'));
+  // DOM can load HTML soup. But, HTML soup can throw warnings, suppress them.
+  @$htmlDom = DOMDocument::loadHTML('<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $text . '</body></html>');
+
+  // The result of DOMDocument->saveXML($bodyNode) is a partial (X)HTML document.
+  // We only need what is inside the body tag.
+  // The "s" modifier makes "." match newlines too.
+  $bodyNode = $htmlDom->getElementsByTagName('body')->item(0);
+  if (preg_match("|^<body[^>]*>(.*)</body>$|s", $htmlDom->saveXML($bodyNode), $matches)) {
+    return $matches[1];
   }
-
-  // Properly entify angles.
-  $text = preg_replace('!<([^a-zA-Z/])!', '&lt;\1', $text);
-
-  // Split tags from text.
-  $split = preg_split('/<([^>]+?)>/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
-  // Note: PHP ensures the array consists of alternating delimiters and literals
-  // and begins and ends with a literal (inserting $null as required).
-
-  $tag = FALSE; // Odd/even counter. Tag or no tag.
-  $stack = array();
-  $output = '';
-  foreach ($split as $value) {
-    // Process HTML tags.
-    if ($tag) {
-      list($tagname) = explode(' ', strtolower($value), 2);
-      // Closing tag
-      if ($tagname{0} == '/') {
-        $tagname = substr($tagname, 1);
-        // Discard XHTML closing tags for single use tags.
-        if (!isset($single_use[$tagname])) {
-          // See if we possibly have a matching opening tag on the stack.
-          if (in_array($tagname, $stack)) {
-            // Close other tags lingering first.
-            do {
-              $output .= '</' . $stack[0] . '>';
-            } while (array_shift($stack) != $tagname);
-          }
-          // Otherwise, discard it.
-        }
-      }
-      // Opening tag
-      else {
-        // See if we have an identical 'no nesting' tag already open and close it if found.
-        if (count($stack) && ($stack[0] == $tagname) && isset($no_nesting[$stack[0]])) {
-          $output .= '</' . array_shift($stack) . '>';
-        }
-        // Push non-single-use tags onto the stack
-        if (!isset($single_use[$tagname])) {
-          array_unshift($stack, $tagname);
-        }
-        // Add trailing slash to single-use tags as per X(HT)ML.
-        else {
-          $value = rtrim($value, ' /') . ' /';
-        }
-        $output .= '<' . $value . '>';
-      }
-    }
-    else {
-      // Passthrough all text.
-      $output .= $value;
-    }
-    $tag = !$tag;
-  }
-  // Close remaining tags.
-  while (count($stack) > 0) {
-    $output .= '</' . array_shift($stack) . '>';
+  else {
+    return "";
   }
-  return $output;
 }
 
 /**
diff --git modules/filter/filter.test modules/filter/filter.test
index ac45317..96cfb74 100644
--- modules/filter/filter.test
+++ modules/filter/filter.test
@@ -178,24 +178,20 @@ class FilterAdminTestCase extends DrupalWebTestCase {
   }
 }
 
-class FilterTestCase extends DrupalWebTestCase {
+/**
+ * Unit tests for core filters.
+ */
+class FilterUnitTest extends DrupalWebTestCase {
   protected $format;
 
   public static function getInfo() {
     return array(
       'name' => t('Core filters'),
-      'description' => t('Filter each filter individually: Convert URLs into links, Convert line breaks, Correct broken HTML, Escape all HTML, Limit allowed HTML tags.'),
+      'description' => t('Filter each filter individually: Convert line breaks, Correct broken HTML.'),
       'group' => t('Filter'),
     );
   }
 
-  function setUp() {
-    parent::setUp();
-
-    $admin_user = $this->drupalCreateUser(array('administer filters', 'create page content'));
-    $this->drupalLogin($admin_user);
-  }
-
   /**
    * Test the line break filter
    */
@@ -215,26 +211,50 @@ class FilterTestCase extends DrupalWebTestCase {
   }
 
   /**
-   * Test the HTML filter
+   * Test the HTML Corrector filter.
    */
-  function testHtmlFilter() {
+  function testHTMLCorrectorFilter() {
+    $test_case = array(
+      // Convert uppercased tag to proper lowercased ones.
+      '<P>test</P>' => '<p>test</p>',
+      '<P>test</p>' => '<p>test</p>',
+      // Let proper XHTML pass thru.
+      'test<hr/>' => 'test<hr/>',
+      'test<hr />' => 'test<hr/>',
+      // Automatically close single tags.
+      'test1<br class="test">test2' => 'test1<br class="test"/>test2',
+      'line1<hr>line2' => 'line1<hr/>line2',
+      'line1<HR>line2' => 'line1<hr/>line2',
+      '<img src="http://example.com/test.jpg">test</img>' => '<img src="http://example.com/test.jpg"/>test',
+      // Move non-inline elements outside of inline containers.
+      '<p>line1<br/><hr/>line2</p>' => '<p>line1<br/></p><hr/>line2',
+      '<p>line1<div>line2</div></p>' => '<p>line1</p><div>line2</div>',
+      // Auto-close improperly nested tags.
+      '<p>test<p>test</p>\n' => '<p>test</p><p>test</p>\n',
+      // Properly close unclosed tags, and remove useless closing tags.
+      '<p>Line1<br><STRONG>bold stuff</b>' => '<p>Line1<br/><strong>bold stuff</strong></p>',
+      // Do not touch HTML comments.
+      '<!-- this is a comment -->' => '<!-- this is a comment -->',
+      'test <!-- comment -->' => 'test <!-- comment -->',
+      // New-lines are accepted and kept as-is.
+      '<p>test\n</p>\n' => '<p>test\n</p>\n',
+      // Encoding is correctly kept.
+      '<p>دروبال' => '<p>دروبال</p>',
+    );
 
+    foreach ($test_case as $input => $expected_output) {
+      $output = _filter_htmlcorrector($input);
+      $this->assertHtmlIdentical($output, $expected_output, t('HTMLCorrector: %input is converted to %output (expected %expected)', array('%input' => $input, '%output' => $output, '%expected' => $expected_output)));
+    }
   }
 
-  function createFormat($filter) {
-    $edit = array(
-      'name' => $this->randomName(),
-      'roles[2]' => TRUE,
-      'filters[filter/' . $filter . ']' => TRUE,
-    );
-    $this->drupalPost('admin/settings/filter/add', $edit, t('Save configuration'));
-    return db_query("SELECT * FROM {filter_format} WHERE name = :name", array(':name' => $edit['name']))->fetchObject();
+  /**
+   * Assert that two HTML strings are identical, except for whitespace before the closing "/>".
+   */
+  function assertHtmlIdentical($input, $expected_output, $message = '', $group = 'Other') {
+    $pattern = '@^' . preg_quote($expected_output, '@') . '$@';
+    $pattern = str_replace('/\>', '\s?/\>', $pattern);
+    return $this->assertTrue(preg_match($pattern, $input), $message ? $message : t('First value is nearly identical to second value'), $group);
   }
 
-  function deleteFormat($format) {
-    if ($format !== NULL) {
-      // Delete new filter.
-      $this->drupalPost('admin/settings/filter/delete/' . $format->format, array(), t('Delete'));
-    }
-  }
 }
diff --git modules/system/system.install modules/system/system.install
index aacff75..e41a81c 100644
--- modules/system/system.install
+++ modules/system/system.install
@@ -266,6 +266,17 @@ function system_requirements($phase) {
   include_once DRUPAL_ROOT . '/includes/unicode.inc';
   $requirements = array_merge($requirements, unicode_requirements());
 
+  // Verify if the DOM PHP5 extension is available.
+  $has_dom = class_exists('DOMDocument');
+  if (!$has_dom) {
+    $requirements['php_dom'] = array(
+      'title' => $t('PHP DOM Extension'),
+      'value' => $t('Not found'),
+      'severity' => REQUIREMENT_ERROR,
+      'description' => $t("The DOM extension is part of PHP5 core, but doesn't seem to be enabled on your system. You need to enable the DOM extension on your PHP installation."),
+    );
+  }
+
   if ($phase == 'runtime') {
     // Check for update status module.
     if (!module_exists('update')) {
