CRM_Utils_XML - Add method filterMarkupText
authorTim Otten <totten@civicrm.org>
Fri, 3 Mar 2023 08:54:08 +0000 (00:54 -0800)
committerTim Otten <totten@civicrm.org>
Fri, 3 Mar 2023 10:18:56 +0000 (02:18 -0800)
CRM/Utils/XML.php
tests/phpunit/CRM/Utils/XMLTest.php [new file with mode: 0644]

index 12eb7a629ee8bd4c7c4bbd25f0a76b8f26c742f8..e817786236b7055157e7fbc7ff46d90c751be88a 100644 (file)
@@ -135,4 +135,108 @@ class CRM_Utils_XML {
     return $arr;
   }
 
+  /**
+   * Apply a filter to the textual parts of the markup.
+   *
+   * @param string $markup
+   *   Ex: '<b>Hello world &amp; universe</b>'
+   * @param callable $filter
+   *   Ex: 'mb_strtoupper'
+   * @return string
+   *   Ex: '<b>HELLO WORLD &amp; UNIVERSE</b>'
+   */
+  public static function filterMarkupText(string $markup, callable $filter): string {
+    $tokens = static::tokenizeMarkupText($markup);
+    foreach ($tokens as &$tokenRec) {
+      if ($tokenRec[0] === 'text') {
+        $tokenRec[1] = htmlentities($filter(html_entity_decode($tokenRec[1])));
+      }
+    }
+    return implode('', array_column($tokens, 1));
+  }
+
+  /**
+   * Split marked-up text into markup and text.
+   *
+   * @param string $markup
+   *   Ex: '<a href="#foo">link</a>'
+   * @return array
+   *   Ex: [
+   *     ['node', '<a href="#foo">'],
+   *     ['text', 'link'],
+   *     ['node', '</a>'],
+   *   ]
+   */
+  protected static function tokenizeMarkupText(string $markup): array {
+    $modes = []; /* text, node, (') quoted attr, (") quoted attr */
+    $tokens = [];
+    $buf = '';
+
+    $startToken = function (string $type) use (&$modes) {
+      array_unshift($modes, $type);
+    };
+
+    $finishToken = function () use (&$tokens, &$buf, &$modes) {
+      $type = array_shift($modes);
+      if ($buf !== '') {
+        $tokens[] = [$type, $buf];
+        $buf = '';
+      }
+    };
+
+    $startToken('text');
+    for ($i = 0; $i < mb_strlen($markup); $i++) {
+      $ch = $markup[$i];
+      switch ($modes[0] . ' ' . $ch) {
+        // Aside: Our style guide makes this harder to read. It's better with 1-case-per-line.
+        case 'text <':
+          $finishToken();
+          $startToken('node');
+          $buf .= $ch;
+          break;
+
+        case 'node >':
+          $buf .= $ch;
+          $finishToken();
+          $startToken('text');
+          break;
+
+        case "node '":
+          $buf .= $ch;
+          array_unshift($modes, "attr'");
+          break;
+
+        case 'node "':
+          $buf .= $ch;
+          array_unshift($modes, 'attr"');
+          break;
+
+        case "attr' '":
+          $buf .= $ch;
+          array_shift($modes);
+          break;
+
+        case 'attr" "':
+          $buf .= $ch;
+          array_shift($modes);
+          break;
+
+        case "attr' \\":
+          $buf .= $markup[$i] . $markup[++$i];
+          break;
+
+        case 'attr" \\':
+          $buf .= $markup[$i] . $markup[++$i];
+          break;
+
+        default:
+          $buf .= $ch;
+          break;
+      }
+    }
+    $finishToken();
+
+    return $tokens;
+  }
+
 }
diff --git a/tests/phpunit/CRM/Utils/XMLTest.php b/tests/phpunit/CRM/Utils/XMLTest.php
new file mode 100644 (file)
index 0000000..2f0f250
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+/**
+ * Class CRM_Utils_XMLTest
+ * @group headless
+ */
+class CRM_Utils_XMLTest extends CiviUnitTestCase {
+
+  /**
+   * Set up for tests.
+   */
+  public function setUp(): void {
+    $this->useTransaction();
+    parent::setUp();
+  }
+
+  public function testFilterMarkupTest(): void {
+    $examples = [
+      ['<b>', 'mb_strtoupper', '<b>'],
+      ['<b>Ok</b>', 'mb_strtoupper', '<b>OK</b>'],
+      ['<b>Ok</b>', 'mb_strtolower', '<b>ok</b>'],
+      ['<b>This &amp; That</b>', 'mb_strtoupper', '<b>THIS &amp; THAT</b>'],
+      ['<b>This &amp; That</b>', 'mb_strtolower', '<b>this &amp; that</b>'],
+      ['One<b>Two</b>Three', 'mb_strtoupper', 'ONE<b>TWO</b>THREE'],
+      ['One<b>Two</b>Three', 'mb_strtolower', 'one<b>two</b>three'],
+      ['<a href="https://example.com/FooBar">The Foo Bar</a>', 'mb_strtoupper', '<a href="https://example.com/FooBar">THE FOO BAR</a>'],
+      ['<a href="https://example.com/FooBar">The Foo Bar</a>', 'mb_strtolower', '<a href="https://example.com/FooBar">the foo bar</a>'],
+      ['<a onclick="window.location=\'https://google.COM\'" target=\'_blank\'>The Foo Bar</a>', 'mb_strtoupper', '<a onclick="window.location=\'https://google.COM\'" target=\'_blank\'>THE FOO BAR</a>'],
+    ];
+    foreach ($examples as $example) {
+      [$input, $filter, $expect] = $example;
+      $actual = CRM_Utils_XML::filterMarkupText($input, $filter);
+      $this->assertEquals($expect, $actual, sprintf('Filter "%s" via "%s"', $input, $filter));
+    }
+  }
+
+}