From cc1b8f3620690148f2c0a2e2d18da4c701cfc330 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Mon, 21 Sep 2026 12:43:18 +0500 Subject: [PATCH 1/4] Upd. ContactsEncoder. Matches search enhanced. Drop attributes logic fixed. --- ContactsEncoder.php | 154 ++++++++++++++++++++++++------- Helper/ContactsEncoderHelper.php | 135 ++++++++++++++++++++++++--- 2 files changed, 245 insertions(+), 44 deletions(-) diff --git a/ContactsEncoder.php b/ContactsEncoder.php index beda1fa..e7af576 100644 --- a/ContactsEncoder.php +++ b/ContactsEncoder.php @@ -299,14 +299,17 @@ public function modifyGlobalEmails($content) } $this->temp_content = $content; + $this->helper->indexMarkup($this->temp_content); $match_cursor = 0; - $replacing_result = preg_replace_callback($this->global_email_pattern, function ($matches) use (&$match_cursor) { + $match_offsets = $this->collectMatchOffsets($this->global_email_pattern, $content); + $match_offset_index = 0; + $replacing_result = preg_replace_callback($this->global_email_pattern, function ($matches) use ($match_offsets, &$match_offset_index, &$match_cursor) { if ( ! isset($matches[0]) ) { return ''; } - $position = $this->advanceMatchCursor($matches[0], $match_cursor); + $position = $this->resolveNextMatchOffset($matches[0], $match_offsets, $match_offset_index, $match_cursor); if ( $this->exclusions->isContactExcluded($matches[0]) ) { return $matches[0]; @@ -316,28 +319,30 @@ public function modifyGlobalEmails($content) return $matches[0]; } - //chek if email is placed in excluded attributes and return unchanged if so - if ( $this->helper->hasAttributeExclusions($matches[0], $this->temp_content, $position) ) { - return $matches[0]; - } - - // skip encoding if the content in script tag - if ( $this->helper->isInsideScriptTag($matches[0], $this->temp_content, $position) ) { - return $matches[0]; - } - - if ( $this->helper->isInsideOptionTag($matches[0], $this->temp_content, $position) ) { + // Raw text of script/style/template blocks is never markup we may rewrite. + if ( $this->helper->isInsideRawTextTag($matches[0], $this->temp_content, $position) ) { return $matches[0]; } + // Drop the cc=/bcc= copies before the markup guards take them out of the flow. if ( $this->helper->isMailtoAdditionalCopy($matches[0], $this->temp_content, $position) ) { return ''; } + // mailto: links are encoded in place inside the href attribute, so they bypass the markup guards. if ( $this->helper->isMailto($matches[0]) ) { return $this->encodeMailtoLink($matches[0]); } + //chek if email is placed in excluded attributes and return unchanged if so + if ( $this->helper->hasAttributeExclusions($matches[0], $this->temp_content, $position) ) { + return $matches[0]; + } + + if ( $this->helper->isInsideOptionTag($matches[0], $this->temp_content, $position) ) { + return $matches[0]; + } + return $this->encodePlainEmail($matches[0]); }, $content); @@ -364,22 +369,31 @@ public function modifyGlobalPhoneNumbers($content) } $this->temp_content = $content; + $this->helper->indexMarkup($this->temp_content); $phones_pattern = $this->global_phones_pattern; $match_cursor = 0; + $match_offsets = $this->collectMatchOffsets($phones_pattern, $content); + $match_offset_index = 0; $replacing_result = preg_replace_callback( $phones_pattern, - function ($matches) use (&$match_cursor) { + function ($matches) use ($match_offsets, &$match_offset_index, &$match_cursor) { if ( ! isset($matches[0]) ) { return ''; } - $position = $this->advanceMatchCursor($matches[0], $match_cursor); + $position = $this->resolveNextMatchOffset($matches[0], $match_offsets, $match_offset_index, $match_cursor); if ( $this->exclusions->isContactExcluded($matches[0]) ) { return $matches[0]; } + // Raw text of script/style/template blocks is never markup we may rewrite. + if ( $this->helper->isInsideRawTextTag($matches[0], $this->temp_content, $position) ) { + return $matches[0]; + } + + // tel: links are encoded in place inside the href attribute, so they bypass the markup guards. if ( $this->helper->isTelTag($matches[0]) ) { return $this->encodeTelLink($matches[0]); } @@ -397,11 +411,6 @@ function ($matches) use (&$match_cursor) { return $matches[0]; } - // check if in script - if ( $this->helper->isInsideScriptTag($matches[0], $this->temp_content, $position) ) { - return $matches[0]; - } - return $this->encodeAny( $matches[0], $this->global_obfuscation_mode, @@ -421,22 +430,66 @@ function ($matches) use (&$match_cursor) { } /** - * Advance the left-to-right cursor so repeated contacts use their own offset. + * Resolve the real offset of the current match. + * + * The offsets are collected up front with PREG_OFFSET_CAPTURE and consumed in order, because + * preg_match_all() and preg_replace_callback() walk the same pattern over the same subject and + * therefore produce the same sequence of matches. Looking the match up with strpos() instead + * would land on an earlier literal occurrence that the pattern did not match, e.g. the address + * inside `` shadowing a real one further down. * * @param string $match + * @param int[] $offsets + * @param int $offset_index * @param int $cursor * @return int|false */ - private function advanceMatchCursor($match, &$cursor) + private function resolveNextMatchOffset($match, $offsets, &$offset_index, &$cursor) { + $length = strlen($match); + + if ( isset($offsets[$offset_index]) ) { + $position = $offsets[$offset_index]; + $offset_index++; + + if ( substr($this->temp_content, $position, $length) === $match ) { + $cursor = $position + $length; + + return $position; + } + } + + // The pre-collected offsets are unusable, fall back to a left-to-right scan. $position = strpos($this->temp_content, $match, $cursor); if ( $position !== false ) { - $cursor = $position + strlen($match); + $cursor = $position + $length; } return $position; } + /** + * Collect the real offset of every match of the pattern in the content. + * + * @param string $pattern + * @param string $content + * @return int[] + */ + private function collectMatchOffsets($pattern, $content) + { + $offsets = array(); + + if ( preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) && isset($matches[0]) ) { + foreach ( $matches[0] as $match ) { + if ( isset($match[1]) ) { + $offsets[] = $match[1]; + } + } + } + + return $offsets; + } + /* * =============== ENCODE ENTITIES =============== */ @@ -823,24 +876,59 @@ protected static function getDefaultReplacingText() * Example: Email * Will be turned to Email * + * The attribute is dropped wherever the address sits inside its value, so + * title="Write to example1@mail.com" is handled as well. Matching is scoped to a + * single tag and a single quoted value, otherwise a greedy match would swallow the rest of the + * markup along with it. + * * @param string $content The content to process. * @return string The content with attributes removed. */ private static function dropAttributesContainEmail($content, $tags) { - $attribute_content_chunk = '[\s]{0,}=[\s]{0,}[\"\']\b[_A-Za-z0-9-\.]+@[_A-Za-z0-9-\.]+\..*\b[\"\']'; + $email_pattern = '/' . self::EMAIL_PATTERN . '/'; + foreach ($tags as $tag => $attribute) { - // Regular expression to match the attribute without the tag - $regexp_chunk_without_tag = "/{$attribute}{$attribute_content_chunk}/"; - // Regular expression to match the attribute with the tag - $regexp_chunk_with_tag = "/<{$tag}.*{$attribute}{$attribute_content_chunk}/"; - // Find all matches of the attribute with the tag in the content - preg_match_all($regexp_chunk_with_tag, $content, $matches); - if (!empty($matches[0])) { - // Remove the attribute without the tag from the content - $content = preg_replace($regexp_chunk_without_tag, '', $content, count($matches[0])); + if ( ! is_string($tag) || $tag === '' || ! is_string($attribute) || $attribute === '' ) { + continue; + } + + // A whole tag, with quoted attribute values consumed as a unit so `>` inside them is kept. + $tag_pattern = '/<' . preg_quote($tag, '/') . '\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>/i'; + // The target attribute inside that tag, value limited to its own quotes. + $attribute_pattern = '/\s' . preg_quote($attribute, '/') . '\s*=\s*(["\'])(.*?)\1/is'; + + $replaced = preg_replace_callback( + $tag_pattern, + static function ($tag_match) use ($attribute_pattern, $email_pattern) { + if ( ! isset($tag_match[0]) ) { + return ''; + } + + $stripped = preg_replace_callback( + $attribute_pattern, + static function ($attribute_match) use ($email_pattern) { + if ( ! isset($attribute_match[0], $attribute_match[2]) ) { + return ''; + } + + return preg_match($email_pattern, $attribute_match[2]) + ? '' + : $attribute_match[0]; + }, + $tag_match[0] + ); + + return $stripped === null ? $tag_match[0] : $stripped; + }, + $content + ); + + if ( $replaced !== null ) { + $content = $replaced; } } + return $content; } diff --git a/Helper/ContactsEncoderHelper.php b/Helper/ContactsEncoderHelper.php index 8b9a912..d03c432 100644 --- a/Helper/ContactsEncoderHelper.php +++ b/Helper/ContactsEncoderHelper.php @@ -31,6 +31,24 @@ class ContactsEncoderHelper */ private $attribute_exclusions_list = array(); + /** + * Tag names whose inner text is not markup and must never be rewritten. + * @var string[] + */ + private $raw_text_tags = array('script', 'style', 'template', 'noscript'); + + /** + * Content the offset indexes below were built for. + * @var string|null + */ + private $indexed_content; + + /** + * Sorted list of [start, end) offsets of every raw text block in the indexed content. + * @var array[] + */ + private $raw_text_ranges = array(); + /** * Checking if the string contains mailto: link * @@ -128,25 +146,120 @@ public function isInsideOptionTag($email, $content, $position = null) */ public function isInsideScriptTag($email, $content, $position = null) { - $pos = $this->resolveMatchPosition($email, $content, $position); - if ($pos === false) { + return $this->isInsideRawTextTag($email, $content, $position, array('script')); + } + + /** + * Check whether the match sits inside the raw text of a tag whose content is not markup + * (script, style, template, noscript). Covers inline scripts, JSON-LD and CSS at once. + * + * @param string $needle The matched contact + * @param string $content The full content + * @param int|false|null $position Known match offset; null looks up the first occurrence + * @param string[]|null $tags Restrict the check to these tags; null uses the full raw text list + * @return bool + */ + public function isInsideRawTextTag($needle, $content, $position = null, $tags = null) + { + $pos = $this->resolveMatchPosition($needle, $content, $position); + if ( $pos === false ) { return false; } - // Find the last script opening tag before the email - $last_script_start = strrpos(substr($content, 0, $pos), 'indexMarkup($content); + + $index = $this->findRangeIndex($this->raw_text_ranges, $pos); + if ( $index === false ) { return false; } - // Find the first script closing tag after the last opening tag - $script_end = strpos($content, '', $last_script_start); - if ($script_end === false) { - return false; + return $tags === null || in_array($this->raw_text_ranges[$index][2], $tags, true); + } + + /** + * Build the raw text offset index for the given content once. + * Repeated calls with the same content reuse the cached index. + * + * @param string $content + * @return void + */ + public function indexMarkup($content) + { + if ( ! is_string($content) ) { + return; } - // The email is inside a script tag if its position is between the opening and closing tags - return ($pos > $last_script_start && $pos < $script_end); + if ( $this->indexed_content !== null && $this->indexed_content === $content ) { + return; + } + + $this->indexed_content = $content; + $this->raw_text_ranges = $this->buildRawTextRanges($content); + } + + /** + * Offsets of the inner text of every raw text tag. + * + * @param string $content + * @return array[] list of [start, end, tag] + */ + private function buildRawTextRanges($content) + { + $ranges = array(); + if ( empty($this->raw_text_tags) ) { + return $ranges; + } + + $tags = array(); + foreach ( $this->raw_text_tags as $tag ) { + if ( is_string($tag) && $tag !== '' ) { + $tags[] = preg_quote($tag, '/'); + } + } + + if ( empty($tags) ) { + return $ranges; + } + + $pattern = '/<(' . implode('|', $tags) . ')\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>(.*?)<\/\1\s*>/is'; + + if ( preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE) && isset($matches[2]) ) { + foreach ( $matches[2] as $index => $match ) { + if ( ! isset($match[0], $match[1]) || $match[1] < 0 ) { + continue; + } + $tag = isset($matches[1][$index][0]) ? strtolower($matches[1][$index][0]) : ''; + $ranges[] = array($match[1], $match[1] + strlen($match[0]), $tag); + } + } + + return $ranges; + } + + /** + * Index of the range containing the offset, or false when the offset is outside all of them. + * + * @param array[] $ranges + * @param int $position + * @return int|false + */ + private function findRangeIndex($ranges, $position) + { + $low = 0; + $high = count($ranges) - 1; + + while ( $low <= $high ) { + $middle = intdiv($low + $high, 2); + if ( $position < $ranges[$middle][0] ) { + $high = $middle - 1; + } elseif ( $position >= $ranges[$middle][1] ) { + $low = $middle + 1; + } else { + return $middle; + } + } + + return false; } /** From aad8094ab68884666dc77f6feefd9e19af477139 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Mon, 21 Sep 2026 12:43:54 +0500 Subject: [PATCH 2/4] Fix. ContactsEncoder. Attribute containment updated. Title duplication fixed. --- ContactsEncoder.php | 39 ++++++++++++++--- Helper/ContactsEncoderHelper.php | 73 ++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/ContactsEncoder.php b/ContactsEncoder.php index e7af576..5d225ea 100644 --- a/ContactsEncoder.php +++ b/ContactsEncoder.php @@ -331,7 +331,7 @@ public function modifyGlobalEmails($content) // mailto: links are encoded in place inside the href attribute, so they bypass the markup guards. if ( $this->helper->isMailto($matches[0]) ) { - return $this->encodeMailtoLink($matches[0]); + return $this->encodeMailtoLink($matches[0], $position); } //chek if email is placed in excluded attributes and return unchanged if so @@ -395,7 +395,7 @@ function ($matches) use ($match_offsets, &$match_offset_index, &$match_cursor) { // tel: links are encoded in place inside the href attribute, so they bypass the markup guards. if ( $this->helper->isTelTag($matches[0]) ) { - return $this->encodeTelLink($matches[0]); + return $this->encodeTelLink($matches[0], $position); } // symbols clearance @@ -557,10 +557,11 @@ protected function encodeAny($string, $mode = Params::OBFUSCATION_MODE_BLUR, $re * Method to process mailto: links. * * @param string $mailto_link_str + * @param int $position Offset of the match inside the content being processed. * * @return string */ - private function encodeMailtoLink($mailto_link_str) + private function encodeMailtoLink($mailto_link_str, $position = 0) { // Get inner tag text and place it in $matches[1] preg_match($this->global_mailto_pattern, $mailto_link_str, $matches); @@ -576,17 +577,18 @@ private function encodeMailtoLink($mailto_link_str) $text = isset($mailto_inner_text) ? $mailto_inner_text : $mailto_link_str; - return 'mailto:' . $text . '" data-original-string="' . $encoded . '" title="' . htmlspecialchars($this->getTooltip(), ENT_QUOTES, 'UTF-8'); + return 'mailto:' . $text . $this->buildSchemeLinkAttributes($encoded, $position); } /** * Method to process tel: links. * * @param string $tel_link_str + * @param int $position Offset of the match inside the content being processed. * * @return string */ - private function encodeTelLink($tel_link_str) + private function encodeTelLink($tel_link_str, $position = 0) { // Get inner tag text and place it in $matches[1] preg_match($this->global_tel_pattern, $tel_link_str, $matches); @@ -603,7 +605,32 @@ private function encodeTelLink($tel_link_str) $text = isset($tel_inner_text) ? $tel_inner_text : $tel_link_str; - return 'tel:' . $text . '" data-original-string="' . $encoded . '" title="' . htmlspecialchars($this->getTooltip(), ENT_QUOTES, 'UTF-8'); + return 'tel:' . $text . $this->buildSchemeLinkAttributes($encoded, $position); + } + + /** + * Builds the attribute tail appended to an opening tag when a scheme link is encoded in place. + * + * The tail intentionally leaves the last attribute value unclosed: the quote that used to close + * the original href value closes it instead. The tooltip is skipped when the tag already carries + * a title attribute, otherwise the markup would end up with a duplicated attribute and the + * author's own title would be wiped by the decoder script. + * + * @param string $encoded Encoded original string. + * @param int $position Offset of the match inside the content being processed. + * + * @return string + */ + private function buildSchemeLinkAttributes($encoded, $position) + { + $has_own_title = $this->helper->enclosingTagHasAttribute($this->temp_content, (int)$position, 'title'); + + if ( $has_own_title ) { + return '" data-original-string="' . $encoded; + } + + return '" title="' . htmlspecialchars($this->getTooltip(), ENT_QUOTES, 'UTF-8') + . '" data-original-string="' . $encoded; } /** diff --git a/Helper/ContactsEncoderHelper.php b/Helper/ContactsEncoderHelper.php index d03c432..6bc1230 100644 --- a/Helper/ContactsEncoderHelper.php +++ b/Helper/ContactsEncoderHelper.php @@ -49,6 +49,79 @@ class ContactsEncoderHelper */ private $raw_text_ranges = array(); + /** + * Checks whether the tag enclosing the given offset already declares the attribute. + * + * Scheme links are rewritten from the inside of the href value, so the encoder appends its own + * attributes to the opening tag. This lookup prevents emitting a duplicate of an attribute the + * author has already set, e.g. `title` on ``. + * + * @param string $content Whole content being processed. + * @param int $position Offset of the match inside $content. + * @param string $attribute Attribute name to look for. + * + * @return bool + */ + public function enclosingTagHasAttribute($content, $position, $attribute) + { + if ( ! is_string($content) || ! is_int($position) || $position < 0 ) { + return false; + } + + $tag_start = strrpos(substr($content, 0, $position), '<'); + if ( $tag_start === false ) { + return false; + } + + $opening_tag = $this->readOpeningTag($content, $tag_start); + if ( $opening_tag === '' ) { + return false; + } + + return (bool)preg_match('/[\s\'"]' . preg_quote($attribute, '/') . '\s*=/i', $opening_tag); + } + + /** + * Reads an opening tag starting at the given offset, honouring quoted attribute values so that + * a `>` inside a value does not terminate the tag prematurely. + * + * @param string $content + * @param int $tag_start Offset of the `<` character. + * + * @return string Empty string when the tag is not closed. + */ + private function readOpeningTag($content, $tag_start) + { + $length = strlen($content); + $quote = null; + + for ( $i = $tag_start + 1; $i < $length; $i++ ) { + $char = $content[$i]; + + if ( $quote !== null ) { + if ( $char === $quote ) { + $quote = null; + } + continue; + } + + if ( $char === '"' || $char === "'" ) { + $quote = $char; + continue; + } + + if ( $char === '>' ) { + return substr($content, $tag_start, $i - $tag_start + 1); + } + + if ( $char === '<' ) { + return ''; + } + } + + return ''; + } + /** * Checking if the string contains mailto: link * From 855a156dbd62685840a76122077fbbb72b0995e9 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Mon, 21 Sep 2026 14:33:34 +0500 Subject: [PATCH 3/4] Fix. ContactsEncoder. Tests PHP 7.4 compatibility. --- ContactsEncoder.php | 4 ++++ Helper/ContactsEncoderHelper.php | 15 +++++++++++++-- tests/.phpcs.xml | 2 +- tests/psalm.xml | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/ContactsEncoder.php b/ContactsEncoder.php index 5d225ea..c36b23f 100644 --- a/ContactsEncoder.php +++ b/ContactsEncoder.php @@ -331,6 +331,8 @@ public function modifyGlobalEmails($content) // mailto: links are encoded in place inside the href attribute, so they bypass the markup guards. if ( $this->helper->isMailto($matches[0]) ) { + $position = $position === false ? 0 : $position; + return $this->encodeMailtoLink($matches[0], $position); } @@ -395,6 +397,8 @@ function ($matches) use ($match_offsets, &$match_offset_index, &$match_cursor) { // tel: links are encoded in place inside the href attribute, so they bypass the markup guards. if ( $this->helper->isTelTag($matches[0]) ) { + $position = $position === false ? 0 : $position; + return $this->encodeTelLink($matches[0], $position); } diff --git a/Helper/ContactsEncoderHelper.php b/Helper/ContactsEncoderHelper.php index 6bc1230..221c189 100644 --- a/Helper/ContactsEncoderHelper.php +++ b/Helper/ContactsEncoderHelper.php @@ -216,6 +216,7 @@ public function isInsideOptionTag($email, $content, $position = null) * @param string $content The full content * @param int|false|null $position Known match offset; null looks up the first occurrence * @return bool + * @psalm-suppress PossiblyUnusedMethod */ public function isInsideScriptTag($email, $content, $position = null) { @@ -242,11 +243,13 @@ public function isInsideRawTextTag($needle, $content, $position = null, $tags = $this->indexMarkup($content); $index = $this->findRangeIndex($this->raw_text_ranges, $pos); - if ( $index === false ) { + if ( $index === false || ! isset($this->raw_text_ranges[$index][2]) ) { return false; } - return $tags === null || in_array($this->raw_text_ranges[$index][2], $tags, true); + $range_tag = $this->raw_text_ranges[$index][2]; + + return $tags === null || in_array($range_tag, $tags, true); } /** @@ -318,11 +321,19 @@ private function buildRawTextRanges($content) */ private function findRangeIndex($ranges, $position) { + if ( ! is_array($ranges) || empty($ranges) ) { + return false; + } + $low = 0; $high = count($ranges) - 1; while ( $low <= $high ) { $middle = intdiv($low + $high, 2); + if ( ! isset($ranges[$middle][0], $ranges[$middle][1]) ) { + return false; + } + if ( $position < $ranges[$middle][0] ) { $high = $middle - 1; } elseif ( $position >= $ranges[$middle][1] ) { diff --git a/tests/.phpcs.xml b/tests/.phpcs.xml index 78a198a..84bb718 100644 --- a/tests/.phpcs.xml +++ b/tests/.phpcs.xml @@ -23,7 +23,7 @@ - + diff --git a/tests/psalm.xml b/tests/psalm.xml index 9729162..98bdf6e 100644 --- a/tests/psalm.xml +++ b/tests/psalm.xml @@ -11,7 +11,7 @@ ensureArrayIntOffsetsExist="true" allowNamedArgumentCalls="false" hoistConstants="true" - phpVersion="5.6" + phpVersion="7.4" hideExternalErrors="true" xsi:schemaLocation="https://getpsalm.org/schema/config config.xsd" limitMethodComplexity="true" From f9672c51f0100f2b105176d68b3dbc3ce977f4dd Mon Sep 17 00:00:00 2001 From: alexandergull Date: Fri, 25 Sep 2026 16:21:37 +0500 Subject: [PATCH 4/4] Fix. ContactsEncoder. Protect raw text blocks in attribute stripping. --- ContactsEncoder.php | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/ContactsEncoder.php b/ContactsEncoder.php index c36b23f..b7b4b75 100644 --- a/ContactsEncoder.php +++ b/ContactsEncoder.php @@ -270,7 +270,7 @@ public function modifyContent($content, $skip_exclusions = false) // will use this in regexp callback $this->temp_content = $content; - $content = self::dropAttributesContainEmail($content, self::$attributes_to_drop); + $content = $this->dropAttributesContainEmail($content, self::$attributes_to_drop); // Main logic @@ -915,9 +915,10 @@ protected static function getDefaultReplacingText() * @param string $content The content to process. * @return string The content with attributes removed. */ - private static function dropAttributesContainEmail($content, $tags) + private function dropAttributesContainEmail($content, $tags) { $email_pattern = '/' . self::EMAIL_PATTERN . '/'; + $this->helper->indexMarkup($content); foreach ($tags as $tag => $attribute) { if ( ! is_string($tag) || $tag === '' || ! is_string($attribute) || $attribute === '' ) { @@ -931,11 +932,22 @@ private static function dropAttributesContainEmail($content, $tags) $replaced = preg_replace_callback( $tag_pattern, - static function ($tag_match) use ($attribute_pattern, $email_pattern) { - if ( ! isset($tag_match[0]) ) { + function ($tag_match) use ($attribute_pattern, $email_pattern, $content) { + if ( ! isset($tag_match[0]) || ! is_array($tag_match[0]) || ! isset($tag_match[0][0], $tag_match[0][1]) ) { return ''; } + $opening_tag = $tag_match[0][0]; + $tag_offset = $tag_match[0][1]; + + if ( ! is_string($opening_tag) || ! is_int($tag_offset) ) { + return ''; + } + + if ( $this->helper->isInsideRawTextTag($opening_tag, $content, $tag_offset) ) { + return $opening_tag; + } + $stripped = preg_replace_callback( $attribute_pattern, static function ($attribute_match) use ($email_pattern) { @@ -947,16 +959,20 @@ static function ($attribute_match) use ($email_pattern) { ? '' : $attribute_match[0]; }, - $tag_match[0] + $opening_tag ); - return $stripped === null ? $tag_match[0] : $stripped; + return $stripped === null ? $opening_tag : $stripped; }, - $content + $content, + -1, + $count, + PREG_OFFSET_CAPTURE ); - if ( $replaced !== null ) { + if ( $replaced !== null && $count > 0 ) { $content = $replaced; + $this->helper->indexMarkup($content); } }