diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php
index 3c849047a9a51..73d848d91cde3 100644
--- a/src/wp-includes/html-api/class-wp-html-tag-processor.php
+++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php
@@ -2576,9 +2576,12 @@ private function apply_attributes_updates( int $shift_this_point ): int {
$accumulated_shift_for_given_point += $shift;
}
- $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied );
+ if ( $diff->start > $bytes_already_copied ) {
+ $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied );
+ }
+
$output_buffer .= $diff->text;
- $bytes_already_copied = $diff->start + $diff->length;
+ $bytes_already_copied = max( $bytes_already_copied, $diff->start + $diff->length );
}
$this->html = $output_buffer . substr( $this->html, $bytes_already_copied );
@@ -4544,17 +4547,27 @@ public function remove_attribute( $name ): bool {
*
* Result:
`.
+ * If an attribute following those separators is removed without removing the
+ * slash, then the remaining slash can become a self-closing flag.
+ *
+ * @since 7.1.0
+ *
+ * @param int $start Byte offset where the attribute starts.
+ * @param int $length Byte length of the attribute.
+ * @return WP_HTML_Span Full span to remove.
+ */
+ private function get_attribute_removal_span( int $start, int $length ): WP_HTML_Span {
+ $tag_name_ends_at = $this->tag_name_starts_at + $this->tag_name_length;
+
+ while ( $start > $tag_name_ends_at && '/' === $this->html[ $start - 1 ] ) {
+ --$start;
+ ++$length;
+ }
+
+ return new WP_HTML_Span( $start, $length );
+ }
+
/**
* Adds a new class name to the currently matched tag.
*
diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php
index 8cece32438bd3..8b002ea4e1fb3 100644
--- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php
+++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php
@@ -606,6 +606,74 @@ public function test_unquoted_slash_attribute_does_not_self_close_foreign_conten
);
}
+ /**
+ * Ensures that removing attributes preserves foreign-content self-closing semantics.
+ *
+ * @covers ::remove_attribute
+ *
+ * @dataProvider data_remove_attribute_preserves_foreign_content_self_closing_semantics
+ *
+ * @param string $html HTML containing a foreign element with an attribute.
+ * @param string $tag_name Foreign element tag name to modify.
+ * @param string $attribute_to_remove Name of the attribute to remove.
+ * @param string $updated_html Expected updated HTML.
+ * @param string $normalized_html Expected normalized HTML after reparsing the updated HTML.
+ * @param bool $expected_has_self_closing_flag Expected self-closing flag state.
+ * @param bool $expected_expects_closer Expected closer expectation.
+ */
+ public function test_remove_attribute_preserves_foreign_content_self_closing_semantics( $html, $tag_name, $attribute_to_remove, $updated_html, $normalized_html, $expected_has_self_closing_flag, $expected_expects_closer ) {
+ $processor = WP_HTML_Processor::create_fragment( $html );
+
+ $this->assertTrue( $processor->next_tag( $tag_name ), "Failed to find the {$tag_name} tag: check test setup." );
+ $this->assertSame(
+ $expected_has_self_closing_flag,
+ $processor->has_self_closing_flag(),
+ 'Test setup has the wrong self-closing flag state.'
+ );
+ $this->assertSame(
+ $expected_expects_closer,
+ $processor->expects_closer(),
+ 'Test setup has the wrong closer expectation.'
+ );
+
+ $this->assertTrue( $processor->remove_attribute( $attribute_to_remove ), 'Could not remove the target attribute.' );
+
+ $this->assertSame(
+ $updated_html,
+ $processor->get_updated_html(),
+ 'Removing the attribute should preserve foreign-content self-closing semantics.'
+ );
+ $this->assertSame(
+ $expected_has_self_closing_flag,
+ $processor->has_self_closing_flag(),
+ 'Removing the attribute should preserve the self-closing flag state.'
+ );
+ $this->assertSame(
+ $expected_expects_closer,
+ $processor->expects_closer(),
+ 'Removing the attribute should preserve the closer expectation.'
+ );
+ $this->assertSame(
+ $normalized_html,
+ WP_HTML_Processor::normalize( $processor->get_updated_html() ),
+ 'Updated HTML should preserve foreign-content self-closing semantics when parsed again.'
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_remove_attribute_preserves_foreign_content_self_closing_semantics() {
+ return array(
+ 'SVG G without self-closing flag' => array( '
', 'G', 'b', '
', '
', false, true ),
+ 'MathML MI without self-closing flag' => array( '
', 'MI', 'b', '
', '
', false, true ),
+ 'SVG G with self-closing flag' => array( '
', 'G', '=', '
', '
', true, false ),
+ 'MathML MI with self-closing flag' => array( '
', 'MI', '=', '
', '
', true, false ),
+ );
+ }
+
/**
* Ensures that expects_closer works for void-like elements in foreign content.
*
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
index 88762ddbb60c4..2095454e234e4 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
@@ -1107,6 +1107,109 @@ public function test_get_attribute_reflects_removed_attribute_before_it_is_appli
);
}
+ /**
+ * Ensures that removing attributes preserves self-closing flag state.
+ *
+ * @covers WP_HTML_Tag_Processor::remove_attribute
+ *
+ * @dataProvider data_remove_attribute_preserves_self_closing_flag_state
+ *
+ * @param string $html HTML containing an attribute to remove.
+ * @param string $attribute_to_remove Name of the attribute to remove.
+ * @param string $expected Expected updated HTML.
+ * @param bool $expected_has_self_closing_flag Expected self-closing flag state.
+ */
+ public function test_remove_attribute_preserves_self_closing_flag_state( $html, $attribute_to_remove, $expected, $expected_has_self_closing_flag ) {
+ $processor = new WP_HTML_Tag_Processor( $html );
+ $this->assertTrue( $processor->next_tag(), 'Could not find the DIV tag: check test setup.' );
+ $this->assertSame(
+ $expected_has_self_closing_flag,
+ $processor->has_self_closing_flag(),
+ 'Test setup has the wrong self-closing flag state.'
+ );
+
+ $processor->remove_attribute( $attribute_to_remove );
+
+ $this->assertSame(
+ $expected,
+ $processor->get_updated_html(),
+ 'Removing the attribute should not change the self-closing flag state.'
+ );
+ $this->assertSame(
+ $expected_has_self_closing_flag,
+ $processor->has_self_closing_flag(),
+ 'Removing the attribute should preserve the self-closing flag state.'
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_remove_attribute_preserves_self_closing_flag_state() {
+ return array(
+ 'Boolean attribute after slash separator' => array( '
', 'b', '
', false ),
+ 'Unquoted attribute after slash separator' => array( '
', 'b', '
', false ),
+ 'Double-quoted attribute after slash separator' => array( '
', 'b', '
', false ),
+ 'Single-quoted attribute after slash separator' => array( "
", 'b', '
', false ),
+ 'Equals-sign attribute after slash separator' => array( '
', '=', '
', false ),
+ 'Duplicate attribute after slash separator' => array( '
', 'b', '
', false ),
+ 'Attribute after multiple slash separators' => array( '
', 'b', '
', false ),
+ 'Boolean attribute before self-closing flag' => array( '
', 'b', '
', true ),
+ 'Equals-sign attribute before self-closing flag' => array( '
', '=', '
', true ),
+ 'Slash-separated attribute before self-closing flag' => array( '
', 'b', '
', true ),
+ 'Spaced boolean attribute before self-closing flag' => array( '
', 'b', '
', true ),
+ 'Spaced equals-sign attribute before self-closing flag' => array( '
', '=', '
', true ),
+ );
+ }
+
+ /**
+ * Ensures that adding and removing attributes with the same insertion point doesn't reinsert the removed text.
+ *
+ * @covers WP_HTML_Tag_Processor::set_attribute
+ * @covers WP_HTML_Tag_Processor::add_class
+ * @covers WP_HTML_Tag_Processor::remove_attribute
+ *
+ * @dataProvider data_can_add_attribute_while_removing_slash_separated_attribute
+ *
+ * @param string $html HTML containing a slash-separated attribute.
+ * @param string $method Method for adding an attribute before removing another.
+ * @param string $expected Expected updated HTML.
+ */
+ public function test_can_add_attribute_while_removing_slash_separated_attribute( $html, $method, $expected ) {
+ $processor = new WP_HTML_Tag_Processor( $html );
+ $this->assertTrue( $processor->next_tag(), 'Could not find the DIV tag: check test setup.' );
+
+ if ( 'set_attribute' === $method ) {
+ $processor->set_attribute( 'a', 'x' );
+ } else {
+ $processor->add_class( 'x' );
+ }
+
+ $processor->remove_attribute( 'b' );
+
+ $this->assertSame(
+ $expected,
+ $processor->get_updated_html(),
+ 'Adding an attribute while removing another should not reinsert the removed slash-separated attribute.'
+ );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_can_add_attribute_while_removing_slash_separated_attribute() {
+ return array(
+ 'Set attribute, no self-closing flag' => array( '
', 'set_attribute', '
' ),
+ 'Add class, no self-closing flag' => array( '
', 'add_class', '
' ),
+ 'Set attribute, self-closing flag' => array( '
', 'set_attribute', '
' ),
+ 'Add class, self-closing flag' => array( '
', 'add_class', '
' ),
+ );
+ }
+
/**
* @ticket 56299
*