From 0f1ffb4343aa1aaec966e6046a0f68b3f1481b91 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Sun, 6 Sep 2026 01:03:50 +0900 Subject: [PATCH] fix(clipboard): do not truncate a clipboard entry mid surrogate pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NormalizeClipboardText caps an entry at kMaxChars with text.resize(). wchar_t is UTF-16 on Windows, so that cap counts code units, and a cut landing between a high and low surrogate leaves a lone high surrogate. The result is not valid UTF-16, and it is what gets serialised into the history store and read back. It takes a non-BMP character — an emoji, or a supplementary-plane Han character, both of which this IME can produce — sitting exactly at the boundary. The entry then carries an unpaired surrogate for as long as it stays in the history. The cut now steps back one unit when it would split a pair, dropping the incomplete character instead of half of it. Entries that do not end on a surrogate are unaffected. MSIME-Linux does not have this: its normalize() truncates with g_utf8_offset_to_pointer, which is codepoint aware. The two implementations otherwise agree exactly, including kMaxItems, kMaxChars and the promote-to-front policy. --- server/src/clipboard/clipboard_history.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/clipboard/clipboard_history.cpp b/server/src/clipboard/clipboard_history.cpp index a1cf1596..1f3d5229 100644 --- a/server/src/clipboard/clipboard_history.cpp +++ b/server/src/clipboard/clipboard_history.cpp @@ -294,7 +294,15 @@ std::wstring NormalizeClipboardText(std::wstring text) while (!text.empty() && (text.back() == L'\0' || text.back() == L'\r')) text.pop_back(); if (text.size() > ClipboardHistory::kMaxChars) - text.resize(ClipboardHistory::kMaxChars); + { + size_t cut = ClipboardHistory::kMaxChars; + // wchar_t is UTF-16 here, so the cut can land between a surrogate pair and leave a lone high + // surrogate, which is not valid UTF-16 and does not survive being written out and read back. + // Drop the incomplete character rather than half of it. + if (cut > 0 && text[cut - 1] >= 0xD800 && text[cut - 1] <= 0xDBFF) + --cut; + text.resize(cut); + } return text; }