From 3f336194f8f2f27e0cbc8171376d32056f33c365 Mon Sep 17 00:00:00 2001 From: Nikita Kuprins Date: Fri, 7 Aug 2026 02:15:37 +0300 Subject: [PATCH 1/6] fix: decode _xHHHH_ escapes when reading inline string cells --- .../analysis/v07/handlers/CellTagHandler.java | 5 + .../sax/SharedStringsTableHandler.java | 54 +-------- .../fesod/sheet/util/XlsxEscapeUtils.java | 82 +++++++++++++ .../handlers/InlineStringUtfDecodeTest.java | 109 ++++++++++++++++++ 4 files changed, 198 insertions(+), 52 deletions(-) create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/analysis/v07/handlers/InlineStringUtfDecodeTest.java diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/analysis/v07/handlers/CellTagHandler.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/analysis/v07/handlers/CellTagHandler.java index 21eb39c6a..3953db5a6 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/analysis/v07/handlers/CellTagHandler.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/analysis/v07/handlers/CellTagHandler.java @@ -37,6 +37,7 @@ import org.apache.fesod.sheet.metadata.GlobalConfiguration; import org.apache.fesod.sheet.metadata.data.ReadCellData; import org.apache.fesod.sheet.read.metadata.holder.xlsx.XlsxReadSheetHolder; +import org.apache.fesod.sheet.util.XlsxEscapeUtils; import org.xml.sax.Attributes; /** @@ -116,6 +117,10 @@ public void endElement(XlsxReadContext xlsxReadContext, String name) { tempCellData.setStringValue(stringValue); break; case DIRECT_STRING: + // Undo the '_xHHHH_' escapes of characters XML forbids + tempCellData.setStringValue(XlsxEscapeUtils.utfDecode(tempDataString)); + tempCellData.setType(CellDataTypeEnum.STRING); + break; case ERROR: tempCellData.setStringValue(tempDataString); tempCellData.setType(CellDataTypeEnum.STRING); diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/analysis/v07/handlers/sax/SharedStringsTableHandler.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/analysis/v07/handlers/sax/SharedStringsTableHandler.java index 8a660b3b1..7c4be66b7 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/analysis/v07/handlers/sax/SharedStringsTableHandler.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/analysis/v07/handlers/sax/SharedStringsTableHandler.java @@ -25,10 +25,9 @@ package org.apache.fesod.sheet.analysis.v07.handlers.sax; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.fesod.sheet.cache.ReadCache; import org.apache.fesod.sheet.constant.ExcelXmlConstants; +import org.apache.fesod.sheet.util.XlsxEscapeUtils; import org.xml.sax.Attributes; import org.xml.sax.helpers.DefaultHandler; @@ -39,8 +38,6 @@ */ public class SharedStringsTableHandler extends DefaultHandler { - private static final Pattern UTF_PATTERN = Pattern.compile("_x([0-9A-Fa-f]{4})_"); - /** * The final piece of data */ @@ -114,7 +111,7 @@ public void endElement(String uri, String localName, String name) { if (currentData == null) { readCache.put(null); } else { - readCache.put(utfDecode(currentData.toString())); + readCache.put(XlsxEscapeUtils.utfDecode(currentData.toString())); } break; case ExcelXmlConstants.SHAREDSTRINGS_RPH_TAG: @@ -137,51 +134,4 @@ public void characters(char[] ch, int start, int length) { } currentElementData.append(ch, start, length); } - - /** - * from poi XSSFRichTextString - * - * @param value the string to decode - * @return the decoded string or null if the input string is null - *

- * For all characters which cannot be represented in XML as defined by the XML 1.0 specification, - * the characters are escaped using the Unicode numerical character representation escape character - * format _xHHHH_, where H represents a hexadecimal character in the character's value. - *

- * Example: The Unicode character 0D is invalid in an XML 1.0 document, - * so it shall be escaped as _x000D_. - *

- * See section 3.18.9 in the OOXML spec. - * @see org.apache.poi.xssf.usermodel.XSSFRichTextString#utfDecode(String) - */ - static String utfDecode(String value) { - if (value == null || !value.contains("_x")) { - return value; - } - - StringBuilder buf = new StringBuilder(); - Matcher m = UTF_PATTERN.matcher(value); - int idx = 0; - while (m.find()) { - int pos = m.start(); - if (pos > idx) { - buf.append(value, idx, pos); - } - - String code = m.group(1); - int icode = Integer.decode("0x" + code); - buf.append((char) icode); - - idx = m.end(); - } - - // small optimization: don't go via StringBuilder if not necessary, - // the encodings are very rare, so we should almost always go via this shortcut. - if (idx == 0) { - return value; - } - - buf.append(value.substring(idx)); - return buf.toString(); - } } diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java new file mode 100644 index 000000000..244c66d84 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Escapes defined by the Office Open XML spec, section 3.18.9. + * + * + */ +public class XlsxEscapeUtils { + + private static final Pattern UTF_PATTERN = Pattern.compile("_x([0-9A-Fa-f]{4})_"); + + private XlsxEscapeUtils() {} + + /** + * from poi XSSFRichTextString + * + * @param value the string to decode + * @return the decoded string or null if the input string is null + *

+ * For all characters which cannot be represented in XML as defined by the XML 1.0 specification, + * the characters are escaped using the Unicode numerical character representation escape character + * format _xHHHH_, where H represents a hexadecimal character in the character's value. + *

+ * Example: The Unicode character 0D is invalid in an XML 1.0 document, + * so it shall be escaped as _x000D_. + *

+ * See section 3.18.9 in the OOXML spec. + * @see org.apache.poi.xssf.usermodel.XSSFRichTextString#utfDecode(String) + */ + public static String utfDecode(String value) { + if (value == null || !value.contains("_x")) { + return value; + } + + StringBuilder buf = new StringBuilder(); + Matcher m = UTF_PATTERN.matcher(value); + int idx = 0; + while (m.find()) { + int pos = m.start(); + if (pos > idx) { + buf.append(value, idx, pos); + } + + String code = m.group(1); + int icode = Integer.decode("0x" + code); + buf.append((char) icode); + + idx = m.end(); + } + + // small optimization: don't go via StringBuilder if not necessary, + // the encodings are very rare, so we should almost always go via this shortcut. + if (idx == 0) { + return value; + } + + buf.append(value.substring(idx)); + return buf.toString(); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/analysis/v07/handlers/InlineStringUtfDecodeTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/analysis/v07/handlers/InlineStringUtfDecodeTest.java new file mode 100644 index 000000000..0786d3192 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/analysis/v07/handlers/InlineStringUtfDecodeTest.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.analysis.v07.handlers; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.testkit.assertions.ExcelAssertions; +import org.apache.fesod.sheet.testkit.base.AbstractExcelTest; +import org.apache.fesod.sheet.testkit.enums.ExcelFormat; +import org.apache.fesod.sheet.testkit.listeners.CollectingReadListener; +import org.apache.fesod.sheet.testkit.models.SimpleData; +import org.apache.fesod.sheet.write.handler.EscapeHexCellWriteHandler; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Regression test for issue #696 + * + *

Characters that XML 1.0 forbids are stored in a cell as {@code _xHHHH_} escapes (ECMA-376, + * section 22.4.2.4), which a reader has to undo. Fesod only did so for one of the two places a cell + * can keep its text: + * + *

+ * + *

Both poi read paths decode it: the SAX {@code XSSFSheetXMLHandler} routes inline strings + * through {@code XSSFRichTextString#getString()}, and the DOM {@code XSSFCell} does the same for + * {@code inlineStr} and {@code str} cells. Fesod's own writer emits inline strings, so the escape + * survived a Fesod write/read round trip. + * + *

Each test asserts the decoded value twice: through the testkit assertions, which pin the value + * the file actually holds, and through Fesod, which is the behaviour under test. + */ +@Tag(Tags.READ) +class InlineStringUtfDecodeTest extends AbstractExcelTest { + + /** The control character from the issue report, kept out of the source as a raw literal. */ + private static final String STX = String.valueOf((char) 0x02); + + @Test + void readDecodesUtfEscapeInInlineStringCell() throws IOException { + File file = createTempFile("inline-string-utf-escape", ExcelFormat.XLSX); + writeName(file, "Product_x0002_Code", false); + + String expected = "Product" + STX + "Code"; + try (ExcelAssertions ea = ExcelAssertions.assertThat(file)) { + ea.sheet(0).row(1).cell(0).hasStringValue(expected); + } + Assertions.assertEquals(expected, readNameWithFesod(file)); + } + + @Test + void escapeHexWriteHandlerRoundTripsLiteralEscape() throws IOException { + File file = createTempFile("escape-hex-round-trip", ExcelFormat.XLSX); + // The handler stores the literal "_xB9f0_" as "_x005F_xB9f0_" so it survives decoding. + writeName(file, "Product_xB9f0_Code", true); + + try (ExcelAssertions ea = ExcelAssertions.assertThat(file)) { + ea.sheet(0).row(1).cell(0).hasStringValue("Product_xB9f0_Code"); + } + Assertions.assertEquals("Product_xB9f0_Code", readNameWithFesod(file)); + } + + private void writeName(File file, String name, boolean escapeHex) { + SimpleData data = new SimpleData(); + data.setName(name); + if (escapeHex) { + FesodSheet.write(file, SimpleData.class) + .registerWriteHandler(new EscapeHexCellWriteHandler()) + .sheet() + .doWrite(Collections.singletonList(data)); + } else { + FesodSheet.write(file, SimpleData.class).sheet().doWrite(Collections.singletonList(data)); + } + } + + private String readNameWithFesod(File file) { + CollectingReadListener listener = new CollectingReadListener<>(); + FesodSheet.read(file, SimpleData.class, listener).sheet().doRead(); + List rows = listener.getRows(); + Assertions.assertEquals(1, rows.size()); + return rows.get(0).getName(); + } +} From 9181018b4c41b82c5e86b234bc17aa833f9ddb47 Mon Sep 17 00:00:00 2001 From: Nikita Kuprins Date: Sat, 15 Aug 2026 00:33:15 +0300 Subject: [PATCH 2/6] test: cut the escape test to one round trip --- .../handlers/InlineStringUtfDecodeTest.java | 109 ------------------ .../readwrite/HexEscapeRoundTripTest.java | 60 ++++++++++ 2 files changed, 60 insertions(+), 109 deletions(-) delete mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/analysis/v07/handlers/InlineStringUtfDecodeTest.java create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/HexEscapeRoundTripTest.java diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/analysis/v07/handlers/InlineStringUtfDecodeTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/analysis/v07/handlers/InlineStringUtfDecodeTest.java deleted file mode 100644 index 0786d3192..000000000 --- a/fesod-sheet/src/test/java/org/apache/fesod/sheet/analysis/v07/handlers/InlineStringUtfDecodeTest.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.fesod.sheet.analysis.v07.handlers; - -import java.io.File; -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import org.apache.fesod.sheet.FesodSheet; -import org.apache.fesod.sheet.testkit.Tags; -import org.apache.fesod.sheet.testkit.assertions.ExcelAssertions; -import org.apache.fesod.sheet.testkit.base.AbstractExcelTest; -import org.apache.fesod.sheet.testkit.enums.ExcelFormat; -import org.apache.fesod.sheet.testkit.listeners.CollectingReadListener; -import org.apache.fesod.sheet.testkit.models.SimpleData; -import org.apache.fesod.sheet.write.handler.EscapeHexCellWriteHandler; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -/** - * Regression test for issue #696 - * - *

Characters that XML 1.0 forbids are stored in a cell as {@code _xHHHH_} escapes (ECMA-376, - * section 22.4.2.4), which a reader has to undo. Fesod only did so for one of the two places a cell - * can keep its text: - * - *

- * - *

Both poi read paths decode it: the SAX {@code XSSFSheetXMLHandler} routes inline strings - * through {@code XSSFRichTextString#getString()}, and the DOM {@code XSSFCell} does the same for - * {@code inlineStr} and {@code str} cells. Fesod's own writer emits inline strings, so the escape - * survived a Fesod write/read round trip. - * - *

Each test asserts the decoded value twice: through the testkit assertions, which pin the value - * the file actually holds, and through Fesod, which is the behaviour under test. - */ -@Tag(Tags.READ) -class InlineStringUtfDecodeTest extends AbstractExcelTest { - - /** The control character from the issue report, kept out of the source as a raw literal. */ - private static final String STX = String.valueOf((char) 0x02); - - @Test - void readDecodesUtfEscapeInInlineStringCell() throws IOException { - File file = createTempFile("inline-string-utf-escape", ExcelFormat.XLSX); - writeName(file, "Product_x0002_Code", false); - - String expected = "Product" + STX + "Code"; - try (ExcelAssertions ea = ExcelAssertions.assertThat(file)) { - ea.sheet(0).row(1).cell(0).hasStringValue(expected); - } - Assertions.assertEquals(expected, readNameWithFesod(file)); - } - - @Test - void escapeHexWriteHandlerRoundTripsLiteralEscape() throws IOException { - File file = createTempFile("escape-hex-round-trip", ExcelFormat.XLSX); - // The handler stores the literal "_xB9f0_" as "_x005F_xB9f0_" so it survives decoding. - writeName(file, "Product_xB9f0_Code", true); - - try (ExcelAssertions ea = ExcelAssertions.assertThat(file)) { - ea.sheet(0).row(1).cell(0).hasStringValue("Product_xB9f0_Code"); - } - Assertions.assertEquals("Product_xB9f0_Code", readNameWithFesod(file)); - } - - private void writeName(File file, String name, boolean escapeHex) { - SimpleData data = new SimpleData(); - data.setName(name); - if (escapeHex) { - FesodSheet.write(file, SimpleData.class) - .registerWriteHandler(new EscapeHexCellWriteHandler()) - .sheet() - .doWrite(Collections.singletonList(data)); - } else { - FesodSheet.write(file, SimpleData.class).sheet().doWrite(Collections.singletonList(data)); - } - } - - private String readNameWithFesod(File file) { - CollectingReadListener listener = new CollectingReadListener<>(); - FesodSheet.read(file, SimpleData.class, listener).sheet().doRead(); - List rows = listener.getRows(); - Assertions.assertEquals(1, rows.size()); - return rows.get(0).getName(); - } -} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/HexEscapeRoundTripTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/HexEscapeRoundTripTest.java new file mode 100644 index 000000000..7a84f1f49 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/HexEscapeRoundTripTest.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.readwrite; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.testkit.base.AbstractExcelTest; +import org.apache.fesod.sheet.testkit.enums.ExcelFormat; +import org.apache.fesod.sheet.testkit.listeners.CollectingReadListener; +import org.apache.fesod.sheet.testkit.models.SimpleData; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Regression test for issue #696: the {@code _xHHHH_} + * escapes were undone only for cells backed by {@code sharedStrings.xml}, so an inline + * string - what the default writer emits - reached the caller with the raw escape. + */ +@Tag(Tags.ROUND_TRIP) +class HexEscapeRoundTripTest extends AbstractExcelTest { + + private static final String STX = String.valueOf((char) 0x02); + + @Test + void readDecodesHexEscapeInCellText() throws IOException { + SimpleData data = new SimpleData(); + data.setName("Product_x0002_Code"); + File file = createTempFile("hex-escape", ExcelFormat.XLSX); + FesodSheet.write(file, SimpleData.class).sheet().doWrite(Collections.singletonList(data)); + + CollectingReadListener listener = new CollectingReadListener<>(); + FesodSheet.read(file, SimpleData.class, listener).sheet().doRead(); + List rows = listener.getRows(); + + Assertions.assertEquals(1, rows.size()); + Assertions.assertEquals("Product" + STX + "Code", rows.get(0).getName()); + } +} From 9e316971d5f0890895172462b58021cd7cf35029 Mon Sep 17 00:00:00 2001 From: Nikita Kuprins Date: Sun, 16 Aug 2026 12:44:53 +0300 Subject: [PATCH 3/6] test: cover utfDecode edge cases, expand javadoc --- .../fesod/sheet/util/XlsxEscapeUtils.java | 15 ++- .../fesod/sheet/util/XlsxEscapeUtilsTest.java | 106 ++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/util/XlsxEscapeUtilsTest.java diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java index 244c66d84..4be3e7581 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java @@ -23,9 +23,18 @@ import java.util.regex.Pattern; /** - * Escapes defined by the Office Open XML spec, section 3.18.9. - * - * + * The {@code _xHHHH_} escapes that xlsx uses for characters XML 1.0 forbids, defined by section 3.18.9 of the Office + * Open XML spec. + *

+ * Every xlsx read path decodes them, whichever part carries the text: + * {@link org.apache.fesod.sheet.analysis.v07.handlers.sax.SharedStringsTableHandler SharedStringsTableHandler} for + * {@code sharedStrings.xml}, and + * {@link org.apache.fesod.sheet.analysis.v07.handlers.CellTagHandler CellTagHandler} for an inline or direct string + * held by the cell itself. POI decodes on both of its own read paths, the DOM {@code XSSFCell} and the streaming + * {@code XSSFSheetXMLHandler}. + *

+ * The convention escapes itself: text that is literally {@code _x0041_} is stored as {@code _x005F_x0041_}, since + * {@code _x005F_} is the escape for the underscore. Decoding it yields the literal back, not {@code A}. */ public class XlsxEscapeUtils { diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/util/XlsxEscapeUtilsTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/util/XlsxEscapeUtilsTest.java new file mode 100644 index 000000000..c25914c74 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/util/XlsxEscapeUtilsTest.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.util; + +import java.util.stream.Stream; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.poi.xssf.usermodel.XSSFRichTextString; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * {@link XlsxEscapeUtils#utfDecode(String)} is a copy of POI's {@code XSSFRichTextString#utfDecode}, so every + * expectation below is what POI produces - see {@link #matchesPoiForEveryInput(String)}, which pins that rather than + * leaving it to the copy staying in step by luck. + */ +@Tag(Tags.UNIT) +class XlsxEscapeUtilsTest { + + static Stream decodesEscapes() { + return Stream.of( + Arguments.of("_x0041_", "A"), + Arguments.of("_x0041_tail", "Atail"), + Arguments.of("head_x0041_", "headA"), + Arguments.of("head_x0041_tail", "headAtail"), + Arguments.of("_x0041__x0042_", "AB"), + Arguments.of("__x0041_", "_A"), + Arguments.of("_x000D_", "\r"), + // the hex digits are case insensitive, the leading x is not - see leavesTextWithoutAnEscapeAlone + Arguments.of("_x00e9_", "é"), + Arguments.of("_x00E9_", "é"), + // an escape whose own underscore is escaped decodes to the literal text, not to the character + Arguments.of("_x005F_x0041_", "_x0041_"), + // as written by Excel - see the sharedStrings.xml of compatibility/t09.xlsx + Arguments.of("SH_x005f_x000D_Z002", "SH_x000D_Z002"), + // a valid escape is decoded even when a malformed one sits next to it + Arguments.of("_x0041_ _xGHIJ_", "A _xGHIJ_"), + // uppercase X is not an escape even once a lowercase one has taken the input past the _x fast path + Arguments.of("_x0041_ _X0042_", "A _X0042_")); + } + + @ParameterizedTest(name = "[{index}] {0} -> {1}") + @MethodSource + void decodesEscapes(String input, String expected) { + Assertions.assertEquals(expected, XlsxEscapeUtils.utfDecode(input)); + } + + static Stream leavesTextWithoutAnEscapeAlone() { + return Stream.of( + "", + "plain text", + "_X0041_", // uppercase X + "_x041_", // three hex digits + "_x00041_", // five hex digits + "_x0041", // no closing underscore + "_x00G1_", // a non-hex digit + "x0041_", // no leading underscore + "_x"); // the marker alone + } + + @ParameterizedTest(name = "[{index}] {0} is left alone") + @MethodSource + void leavesTextWithoutAnEscapeAlone(String input) { + Assertions.assertEquals(input, XlsxEscapeUtils.utfDecode(input)); + } + + @Test + void returnsNullForNull() { + Assertions.assertNull(XlsxEscapeUtils.utfDecode(null)); + } + + /** + * POI is the reference for this decoding, so it is also the oracle: any input where the two disagree is a defect + * here, whatever the table above says. + */ + @ParameterizedTest(name = "[{index}] {0} decodes as POI does") + @MethodSource + void matchesPoiForEveryInput(String input) { + Assertions.assertEquals(new XSSFRichTextString(input).getString(), XlsxEscapeUtils.utfDecode(input)); + } + + static Stream matchesPoiForEveryInput() { + return Stream.concat( + decodesEscapes().map(arguments -> (String) arguments.get()[0]), leavesTextWithoutAnEscapeAlone()); + } +} From 180677be2e68343fa68875a1795a6dc173bddc71 Mon Sep 17 00:00:00 2001 From: Nikita Kuprins Date: Sun, 16 Aug 2026 13:20:04 +0300 Subject: [PATCH 4/6] docs: cross-reference the two halves of the escape --- .../java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java | 7 +++++++ .../sheet/write/handler/EscapeHexCellWriteHandler.java | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java index 4be3e7581..64f42d4ec 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/XlsxEscapeUtils.java @@ -35,6 +35,13 @@ *

* The convention escapes itself: text that is literally {@code _x0041_} is stored as {@code _x005F_x0041_}, since * {@code _x005F_} is the escape for the underscore. Decoding it yields the literal back, not {@code A}. + *

+ * The write half of the same convention lives in + * {@link org.apache.fesod.sheet.write.handler.EscapeHexCellWriteHandler EscapeHexCellWriteHandler}, which produces + * that {@code _x005F_x} form. Both sides read {@code _xHHHH_} the same way, so a change to what counts as an escape + * belongs in both. + * + * @see org.apache.fesod.sheet.write.handler.EscapeHexCellWriteHandler */ public class XlsxEscapeUtils { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/EscapeHexCellWriteHandler.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/EscapeHexCellWriteHandler.java index 6e5de6c24..2ccc84167 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/EscapeHexCellWriteHandler.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/EscapeHexCellWriteHandler.java @@ -35,6 +35,13 @@ *

* To store the literal _xHHHH_ sequence without it being decoded by POI, we need to escape the initial underscore by * replacing _x with _x005F_x. + *

+ * The read half of the same convention lives in + * {@link org.apache.fesod.sheet.util.XlsxEscapeUtils#utfDecode(String) XlsxEscapeUtils.utfDecode}, which undoes what + * this handler writes. Both sides read {@code _xHHHH_} the same way, so a change to what counts as an escape belongs + * in both. + * + * @see org.apache.fesod.sheet.util.XlsxEscapeUtils#utfDecode(String) */ public class EscapeHexCellWriteHandler implements CellWriteHandler { From 5339443275cbd9abba8077027160632de9dd1e71 Mon Sep 17 00:00:00 2001 From: Nikita Kuprins Date: Sun, 16 Aug 2026 15:33:21 +0300 Subject: [PATCH 5/6] test: build the escape with the write handler --- .../sheet/readwrite/HexEscapeRoundTripTest.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/HexEscapeRoundTripTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/HexEscapeRoundTripTest.java index 7a84f1f49..fba19522e 100644 --- a/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/HexEscapeRoundTripTest.java +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/HexEscapeRoundTripTest.java @@ -29,6 +29,7 @@ import org.apache.fesod.sheet.testkit.enums.ExcelFormat; import org.apache.fesod.sheet.testkit.listeners.CollectingReadListener; import org.apache.fesod.sheet.testkit.models.SimpleData; +import org.apache.fesod.sheet.write.handler.EscapeHexCellWriteHandler; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -41,20 +42,26 @@ @Tag(Tags.ROUND_TRIP) class HexEscapeRoundTripTest extends AbstractExcelTest { - private static final String STX = String.valueOf((char) 0x02); - + /** + * The handler is what puts a real escape in the cell, storing the literal as {@code Product_x005F_x0002_Code}. + * Taking it from the writer's own output instead would tie the expectation to a writer default, not to the + * reader under test. + */ @Test - void readDecodesHexEscapeInCellText() throws IOException { + void escapedOnWrite_readsBackAsTheLiteral() throws IOException { SimpleData data = new SimpleData(); data.setName("Product_x0002_Code"); File file = createTempFile("hex-escape", ExcelFormat.XLSX); - FesodSheet.write(file, SimpleData.class).sheet().doWrite(Collections.singletonList(data)); + FesodSheet.write(file, SimpleData.class) + .registerWriteHandler(new EscapeHexCellWriteHandler()) + .sheet() + .doWrite(Collections.singletonList(data)); CollectingReadListener listener = new CollectingReadListener<>(); FesodSheet.read(file, SimpleData.class, listener).sheet().doRead(); List rows = listener.getRows(); Assertions.assertEquals(1, rows.size()); - Assertions.assertEquals("Product" + STX + "Code", rows.get(0).getName()); + Assertions.assertEquals("Product_x0002_Code", rows.get(0).getName()); } } From a7df68aa38b98149d6f9146f8b629def87592059 Mon Sep 17 00:00:00 2001 From: Nikita Kuprins Date: Sun, 16 Aug 2026 15:36:01 +0300 Subject: [PATCH 6/6] docs: note that the handler is opt-in --- .../fesod/sheet/write/handler/EscapeHexCellWriteHandler.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/EscapeHexCellWriteHandler.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/EscapeHexCellWriteHandler.java index 2ccc84167..f3e7d904f 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/EscapeHexCellWriteHandler.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/EscapeHexCellWriteHandler.java @@ -36,6 +36,10 @@ * To store the literal _xHHHH_ sequence without it being decoded by POI, we need to escape the initial underscore by * replacing _x with _x005F_x. *

+ * This handler is not registered by default. Without it the writer stores {@code _xHHHH_}-shaped text exactly as + * typed, and any reader that follows the convention - Fesod, POI or Excel - decodes it back to the character it + * names, so the literal does not survive a round trip. Register it on the write to keep such text intact. + *

* The read half of the same convention lives in * {@link org.apache.fesod.sheet.util.XlsxEscapeUtils#utfDecode(String) XlsxEscapeUtils.utfDecode}, which undoes what * this handler writes. Both sides read {@code _xHHHH_} the same way, so a change to what counts as an escape belongs