diff --git a/core/spring-boot/src/main/java/org/springframework/boot/json/AppendableByteArray.java b/core/spring-boot/src/main/java/org/springframework/boot/json/AppendableByteArray.java index e0274f6be61..430e15ad88e 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/json/AppendableByteArray.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/json/AppendableByteArray.java @@ -56,6 +56,8 @@ class AppendableByteArray implements Appendable { private ByteBuffer out; + private char highSurrogate; + AppendableByteArray(Charset charset) { this(charset, DEFAULT_INITIAL_SIZE, DEFAULT_EXPANSION_SIZE); } @@ -89,8 +91,19 @@ private AppendableByteArray append(char[] chars) throws IOException { } private AppendableByteArray append(CharBuffer in) throws IOException { - CoderResult result = this.encoder.encode(in, this.out, false); + if (this.highSurrogate != 0) { + CharBuffer pending = CharBuffer.allocate(in.remaining() + 1); + pending.put(this.highSurrogate).put(in).flip(); + this.highSurrogate = 0; + in = pending; + } + return append(in, false); + } + + private AppendableByteArray append(CharBuffer in, boolean endOfInput) throws IOException { + CoderResult result = this.encoder.encode(in, this.out, endOfInput); if (result.isUnderflow()) { + this.highSurrogate = (in.hasRemaining()) ? in.get() : 0; return this; } if (result.isOverflow()) { @@ -98,13 +111,18 @@ private AppendableByteArray append(CharBuffer in) throws IOException { this.out = ByteBuffer.allocate(out.capacity() + this.expansionSize); out.flip(); this.out.put(out); - return append(in); + return append(in, endOfInput); } result.throwException(); return this; } - byte[] toByteArray() { + byte[] toByteArray() throws IOException { + if (this.highSurrogate != 0) { + CharBuffer in = CharBuffer.wrap(new char[] { this.highSurrogate }); + this.highSurrogate = 0; + append(in, true); + } this.out.flip(); int limit = this.out.limit(); int position = this.out.position(); @@ -120,6 +138,7 @@ byte[] toByteArray() { private void reset() { this.out.clear(); this.encoder.reset(); + this.highSurrogate = 0; } static byte[] toByteArray(Charset charset, ThrowingConsumer appendable) throws IOException { diff --git a/core/spring-boot/src/test/java/org/springframework/boot/json/AppendableByteArrayTests.java b/core/spring-boot/src/test/java/org/springframework/boot/json/AppendableByteArrayTests.java index 5746991f0a0..46e587128dd 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/json/AppendableByteArrayTests.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/json/AppendableByteArrayTests.java @@ -90,6 +90,22 @@ void toByteArrayWhenPreviousUseWasAbandonedReturnsCleanInstance() throws IOExcep assertThat(reused).isEqualTo("clean".getBytes(StandardCharsets.UTF_8)); } + @Test + void writesSurrogatePairAppendedAsIndividualChars() throws Exception { + assertByteArray(StandardCharsets.UTF_8, (appendable) -> appendable.append('\uD83D').append('\uDE00')); + assertByteArray(StandardCharsets.UTF_16, (appendable) -> appendable.append('\uD83D').append('\uDE00')); + } + + @Test + void writesSurrogatePairSplitAcrossAppendedStrings() throws Exception { + assertByteArray(StandardCharsets.UTF_8, (appendable) -> appendable.append("a\uD83D").append("\uDE00b")); + } + + @Test + void writesUnpairedHighSurrogate() throws Exception { + assertByteArray(StandardCharsets.UTF_8, (appendable) -> appendable.append('\uD83D')); + } + private void assertByteArray(Charset charset, ThrowingConsumer action) throws Exception { assertByteArray(4, 4, charset, action); } diff --git a/core/spring-boot/src/test/java/org/springframework/boot/json/WritableJsonTests.java b/core/spring-boot/src/test/java/org/springframework/boot/json/WritableJsonTests.java index aa3ea133f04..a9d176c93e3 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/json/WritableJsonTests.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/json/WritableJsonTests.java @@ -66,6 +66,17 @@ void toByteArrayReturnsByteArray() { assertThat(writable.toByteArray()).isEqualTo("{}".getBytes()); } + @Test + void toByteArrayWhenContentIsAppendedCharByCharWritesSupplementaryCharacters() { + String emoji = new String(Character.toChars(0x1F600)); + WritableJson writable = (out) -> { + for (int i = 0; i < emoji.length(); i++) { + out.append(emoji.charAt(i)); + } + }; + assertThat(writable.toByteArray(StandardCharsets.UTF_8)).isEqualTo(emoji.getBytes(StandardCharsets.UTF_8)); + } + @Test void toResourceWritesJson() throws Exception { File file = new File(this.temp, "out.json");