From ea3df4b38171a64c4911d4e915fb9e726b7129f3 Mon Sep 17 00:00:00 2001
From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com>
Date: Thu, 23 Jul 2026 15:35:56 -0600
Subject: [PATCH] Fix grapheme-vs-UTF-16 range bug in RichContentFormatter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
RichContentFormatter built its NSRanges from `content.count` (Swift grapheme count), but NSRegularExpression matches over UTF-16. With multi-code-unit characters (emoji, flags, combining sequences) the grapheme count is shorter than the UTF-16 length, so the search range was truncated and any tag or style near the end silently escaped stripping. removeTrailingBreakTags also fed a UTF-16 match offset to String.index(_:offsetBy:), which counts graphemes — right for ASCII, a crash once the range was corrected.
Range over UTF-16 via `String.utf16.count`, and convert the trailing-BR match with Range(_:in:).
Adds one isolated test per fix site — each forbidden-tag, div/paragraph, filterNewLines, inline-style, and trailing-break site, plus the trailing-break index-offset cut — using astral emoji, ZWJ sequences, flags, keycaps, skin-tone modifiers, and an NFD combining mark, so reverting any single site breaks exactly one test (verified by reverting each). Two further tests pin the exact off-by-one boundary and confirm the corrected range strips the intended tag rather than everything. Each fails on the old code and passes now, and the exact-output assertions confirm the clusters survive byte-for-byte.
---
.../Utility/RichContentFormatter.swift | 29 +++--
.../RichContentFormatterTests.swift | 110 ++++++++++++++++++
2 files changed, 124 insertions(+), 15 deletions(-)
diff --git a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift
index 38530a2d2043..05e44922b3ca 100644
--- a/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift
+++ b/Modules/Sources/WordPressShared/Utility/RichContentFormatter.swift
@@ -50,17 +50,17 @@ import Foundation
content = RegEx.styleTags.stringByReplacingMatches(in: content,
options: .reportCompletion,
- range: NSRange(location: 0, length: content.count),
+ range: NSRange(location: 0, length: content.utf16.count),
withTemplate: "")
content = RegEx.scriptTags.stringByReplacingMatches(in: content,
options: .reportCompletion,
- range: NSRange(location: 0, length: content.count),
+ range: NSRange(location: 0, length: content.utf16.count),
withTemplate: "")
content = RegEx.gutenbergComments.stringByReplacingMatches(in: content,
options: .reportCompletion,
- range: NSRange(location: 0, length: content.count),
+ range: NSRange(location: 0, length: content.utf16.count),
withTemplate: "")
return content
@@ -84,23 +84,23 @@ import Foundation
// Convert div tags to p tags
content = RegEx.divTagsStart.stringByReplacingMatches(in: content,
options: .reportCompletion,
- range: NSRange(location: 0, length: content.count),
+ range: NSRange(location: 0, length: content.utf16.count),
withTemplate: openPTag)
content = RegEx.divTagsEnd.stringByReplacingMatches(in: content,
options: .reportCompletion,
- range: NSRange(location: 0, length: content.count),
+ range: NSRange(location: 0, length: content.utf16.count),
withTemplate: closePTag)
// Remove duplicate/redundant p tags.
content = RegEx.pTagsStart.stringByReplacingMatches(in: content,
options: .reportCompletion,
- range: NSRange(location: 0, length: content.count),
+ range: NSRange(location: 0, length: content.utf16.count),
withTemplate: openPTag)
content = RegEx.pTagsEnd.stringByReplacingMatches(in: content,
options: .reportCompletion,
- range: NSRange(location: 0, length: content.count),
+ range: NSRange(location: 0, length: content.utf16.count),
withTemplate: closePTag)
content = filterNewLines(content)
@@ -114,11 +114,11 @@ import Foundation
var ranges = [NSRange]()
// We don't want to remove new lines from preformatted tag blocks,
// so get the ranges of such blocks.
- let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count))
+ let matches = RegEx.preTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count))
if matches.isEmpty {
// No blocks found, so we'll parse the whole string.
- ranges.append(NSRange(location: 0, length: content.count))
+ ranges.append(NSRange(location: 0, length: content.utf16.count))
} else {
// One or more preformatted blocks found, we don't want to remove new lines
@@ -133,7 +133,7 @@ import Foundation
location = match.range.location + match.range.length
}
- length = content.count - location
+ length = content.utf16.count - location
ranges.append(NSRange(location: location, length: length))
}
@@ -163,7 +163,7 @@ import Foundation
content = RegEx.styleAttr.stringByReplacingMatches(in: content,
options: .reportCompletion,
- range: NSRange(location: 0, length: content.count),
+ range: NSRange(location: 0, length: content.utf16.count),
withTemplate: "")
return content
@@ -206,10 +206,9 @@ import Foundation
}
var content = string.trim()
- let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.count))
- if let match = matches.first {
- let index = content.index(content.startIndex, offsetBy: match.range.location)
- content = String(content.prefix(upTo: index))
+ let matches = RegEx.trailingBRTags.matches(in: content, options: .reportCompletion, range: NSRange(location: 0, length: content.utf16.count))
+ if let match = matches.first, let matchRange = Range(match.range, in: content) {
+ content = String(content[.. even after a skin-tone emoji.
+ let out = RichContentFormatter.normalizeParagraphs("👍🏽 ")
+ }
+
+ func testNFDCombiningDivEndNotConvertedInTail() {
+ //
is collapsed to a single
. + let out = RichContentFormatter.normalizeParagraphs("😀
") + XCTAssertEqual(out, "😀
") + } + + func testNormalizeParagraphsMergesTrailingDoubleCloseParagraph() { + // A redundant
is collapsed to a single . + let out = RichContentFormatter.normalizeParagraphs("😀") + XCTAssertEqual(out, "😀") + } + + func testFilterNewLinesNoPreFallbackRemovesNewlinePastWideCluster() { + // A newline outside any block is removed.
+ let out = RichContentFormatter.filterNewLines("👨👩👧👦\nA")
+ XCTAssertEqual(out, "👨👩👧👦A")
+ }
+
+ func testFilterNewLinesElseBranchPreservesTrailingNewlineAfterWideCluster() {
+ // With a block present, a newline that follows it (outside the block) is still removed.
+ let out = RichContentFormatter.filterNewLines("\n
👨👩👧👦\nZ")
+ XCTAssertEqual(out, "\n
👨👩👧👦Z")
+ }
+
+ func testFilterNewLinesMultiPreInverseRanges() {
+ // Across several blocks: newlines inside them are kept, newlines outside are removed.
+ let out = RichContentFormatter.filterNewLines("👨👩👧👦\na\nb
\n😀\nc\nd
\n🇺🇸\n")
+ XCTAssertEqual(out, "👨👩👧👦a\nb
😀c\nd
🇺🇸")
+ }
+
+ func testZWJFamilyStyleAttrSurvivesInTruncatedTail() {
+ // An inline style attribute after a family emoji is stripped.
+ let out = RichContentFormatter.removeInlineStyles("👨👩👧👦")
+ XCTAssertEqual(out, "👨👩👧👦")
+ }
+
+ func testZWJFamilyTrailingBreakSurvivesAndCutsCleanly() {
+ // A trailing
after a family emoji is removed, and the emoji before it stays intact.
+ let out = RichContentFormatter.removeTrailingBreakTags("👨👩👧👦text
")
+ XCTAssertEqual(out, "👨👩👧👦text")
+ }
+
+ func testTrailingBreakOnlyFinalRemovedEmojiIntact() {
+ // Only the trailing
is removed; an earlier
in the middle of the text stays.
+ let out = RichContentFormatter.removeTrailingBreakTags("😀
text
")
+ XCTAssertEqual(out, "😀
text")
+ }
+
+ func testForbiddenCleanMultibyteUnchanged() {
+ // Content with no tags to strip passes through unchanged.
+ let out = RichContentFormatter.removeForbiddenTags("Hello 👨👩👧👦 world 😀!")
+ XCTAssertEqual(out, "Hello 👨👩👧👦 world 😀!")
+ }
+
+ // MARK: - Boundary + selectivity (not new fix sites)
+
+ func testBoundaryStraddleOffByOne() {
+ // One emoji makes the range exactly one UTF-16 unit short, and the token's closing ">"
+ // is exactly that dropped unit — pins the off-by-one where the wide-gap cases have slack.
+ let out = RichContentFormatter.removeForbiddenTags("text")
+ XCTAssertEqual(out, "text")
+ }
+
+ func testStripsTagInRangeAndInTailNotJustEverything() {
+ // The first style attribute is always in range; the ZWJ family pushes the second into the
+ // truncated tail. The fix strips both; the bug strips only the first — so the range, not a
+ // blanket "strip everything", decides which tags go.
+ let out = RichContentFormatter.removeInlineStyles("👨👩👧👦")
+ XCTAssertEqual(out, "👨👩👧👦")
+ }
}