From 76d8d2ef28761853e103ce1d2f9e9a65121fd89e Mon Sep 17 00:00:00 2001 From: Sean-Walker0 <309789691+Sean-Walker0@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:51:10 +0800 Subject: [PATCH] fix(sheet): escape hex sequences in head and multi-variable fill cells The default EscapeHexCellWriteHandler only sees cells that pass through the converter flow (afterCellDataConverted), so two XLSX write paths stored literal _xHHHH_ sequences unescaped and POI's reader decoded them into the characters they name: - head cells: WriteContextImpl wrote the head string via setCellValue directly; - multi-variable fill cells: ExcelWriteFillExecutor converted each variable value but assembled the surrounding template text itself and stored the joined string directly. A head "_x0041_ col" round-tripped as "A col" and a fill cell "p {a} s" with a=_x0041_ x as "p A x s". Share the escape scanner as XlsxEscapeUtils.escape (the write half of the existing utfDecode), have the handler delegate to it, apply it to the head string for XLSX writes, and apply it to the joined fill string for XLSX writes. Both paths stay outside the handler event flow, keeping the documented CellWriteHandler call matrix untouched. Verified with EscapeHexHeadAndFillWriteTest (both cases decode and corrupt before the change and pass after), the existing escape suites and WriteHandlerTest unchanged, plus the full fesod-sheet suite (1049 tests) and spotless:check. --- .../fesod/sheet/context/WriteContextImpl.java | 12 ++- .../fesod/sheet/util/XlsxEscapeUtils.java | 80 ++++++++++++++++ .../executor/ExcelWriteFillExecutor.java | 10 +- .../impl/EscapeHexCellWriteHandler.java | 76 +-------------- .../EscapeHexHeadAndFillWriteTest.java | 94 +++++++++++++++++++ 5 files changed, 196 insertions(+), 76 deletions(-) create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/write/handler/EscapeHexHeadAndFillWriteTest.java diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/context/WriteContextImpl.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/context/WriteContextImpl.java index 17fcc7aad..73fd87443 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/context/WriteContextImpl.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/context/WriteContextImpl.java @@ -52,6 +52,7 @@ import org.apache.fesod.sheet.util.NumberUtils; import org.apache.fesod.sheet.util.WorkBookUtil; import org.apache.fesod.sheet.util.WriteHandlerUtils; +import org.apache.fesod.sheet.util.XlsxEscapeUtils; import org.apache.fesod.sheet.write.handler.context.CellWriteHandlerContext; import org.apache.fesod.sheet.write.handler.context.RowWriteHandlerContext; import org.apache.fesod.sheet.write.handler.context.SheetWriteHandlerContext; @@ -379,10 +380,19 @@ private void addOneRowOfHeadDataToExcel( WriteCellData writeCellData = new WriteCellData<>(head.getHeadNameList().get(relativeRowIndex)); - cell.setCellValue(writeCellData.getStringValue()); + // Head strings are stored outside the converter flow, so the default xlsx hex escaping that + // data cells receive through EscapeHexCellWriteHandler is applied here directly. Emitted as + // static escaping instead of a handler event to keep the documented CellWriteHandler call + // matrix unchanged. + String headString = writeCellData.getStringValue(); + if (writeWorkbookHolder().getExcelType() == ExcelTypeEnum.XLSX) { + headString = XlsxEscapeUtils.escape(headString); + writeCellData.setStringValue(headString); + } cellWriteHandlerContext.setCellDataList(ListUtils.newArrayList(writeCellData)); cellWriteHandlerContext.setFirstCellData(writeCellData); + cell.setCellValue(writeCellData.getStringValue()); WriteHandlerUtils.afterCellDispose(cellWriteHandlerContext); } } 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 6c0ff6fb0..67aaa985c 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 @@ -50,6 +50,86 @@ public class XlsxEscapeUtils { private XlsxEscapeUtils() {} + // ASCII hex digits only. Not Character.digit(c, 16), which also accepts non-ASCII + // digits such as U+0663 that OOXML never uses. + private static final boolean[] HEX_TABLE = new boolean[128]; + + private static final String ESCAPE_PREFIX = "_x"; + private static final String ESCAPED_PREFIX = "_x005F_x"; + private static final int PREFIX_LENGTH = ESCAPE_PREFIX.length(); + private static final int HEX_DIGIT_COUNT = 4; + + static { + for (char c = '0'; c <= '9'; c++) HEX_TABLE[c] = true; + for (char c = 'A'; c <= 'F'; c++) HEX_TABLE[c] = true; + for (char c = 'a'; c <= 'f'; c++) HEX_TABLE[c] = true; + } + + /** + * Replaces every {@code _xHHHH_} sequence with {@code _x005F_xHHHH_} so a reader decodes the literal back + * instead of the character the sequence names. The write half of {@link #utfDecode(String)}. + * + * @param originalString the string to escape + * @return the escaped string, or the input unchanged when it holds no escape sequence + */ + public static String escape(String originalString) { + if (originalString == null || !originalString.contains(ESCAPE_PREFIX)) { + return originalString; + } + int length = originalString.length(); + + // Lazily allocated: stays null (no allocation) when no valid pattern is found + StringBuilder result = null; + int lastEnd = 0; + int searchStart = 0; + int patternIndex; + while ((patternIndex = originalString.indexOf(ESCAPE_PREFIX, searchStart)) != -1) { + int hexStart = patternIndex + PREFIX_LENGTH; + int suffixIndex = hexStart + HEX_DIGIT_COUNT; + int patternEnd = suffixIndex + 1; + // Too few characters left for a full pattern, and any later match has even fewer + if (patternEnd > length) { + break; + } + + if (originalString.charAt(suffixIndex) == '_' && isHexDigits(originalString, hexStart)) { + if (result == null) { + result = new StringBuilder(length + 64); + } + // Append content since the previous match, then the escaped pattern + result.append(originalString, lastEnd, patternIndex); + result.append(ESCAPED_PREFIX); + result.append(originalString, hexStart, suffixIndex); + result.append('_'); + lastEnd = patternEnd; + searchStart = patternEnd; + } else { + searchStart = hexStart; + } + } + + // No valid patterns found + if (result == null) { + return originalString; + } + + result.append(originalString, lastEnd, length); + return result.toString(); + } + + /** + * Checks whether the four characters starting at {@code startIndex} are all ASCII hex digits. + */ + private static boolean isHexDigits(String str, int startIndex) { + for (int i = 0; i < HEX_DIGIT_COUNT; i++) { + char c = str.charAt(startIndex + i); + if (c >= 128 || !HEX_TABLE[c]) { + return false; + } + } + return true; + } + /** * from poi XSSFRichTextString * diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/executor/ExcelWriteFillExecutor.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/executor/ExcelWriteFillExecutor.java index fb743c8f1..42df2c487 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/executor/ExcelWriteFillExecutor.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/executor/ExcelWriteFillExecutor.java @@ -50,11 +50,13 @@ import org.apache.fesod.sheet.exception.ExcelGenerateException; import org.apache.fesod.sheet.metadata.data.WriteCellData; import org.apache.fesod.sheet.metadata.property.ExcelContentProperty; +import org.apache.fesod.sheet.support.ExcelTypeEnum; import org.apache.fesod.sheet.util.BeanMapUtils; import org.apache.fesod.sheet.util.ClassUtils; import org.apache.fesod.sheet.util.FieldUtils; import org.apache.fesod.sheet.util.PoiUtils; import org.apache.fesod.sheet.util.WriteHandlerUtils; +import org.apache.fesod.sheet.util.XlsxEscapeUtils; import org.apache.fesod.sheet.write.handler.context.CellWriteHandlerContext; import org.apache.fesod.sheet.write.handler.context.RowWriteHandlerContext; import org.apache.fesod.sheet.write.metadata.fill.AnalysisCell; @@ -317,7 +319,13 @@ private void doFill( } } cellValueBuild.append(analysisCell.getPrepareDataList().get(index)); - cell.setCellValue(cellValueBuild.toString()); + String joined = cellValueBuild.toString(); + if (writeContext.writeWorkbookHolder().getExcelType() == ExcelTypeEnum.XLSX) { + // The variable values above never passed through afterCellDataConverted, so the + // joined literal still needs the default xlsx hex escaping before it is stored. + joined = XlsxEscapeUtils.escape(joined); + } + cell.setCellValue(joined); cellWriteHandlerContext.setCellDataList(cellDataList); if (CollectionUtils.isNotEmpty(cellDataList)) { cellWriteHandlerContext.setFirstCellData(cellDataList.get(0)); diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/impl/EscapeHexCellWriteHandler.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/impl/EscapeHexCellWriteHandler.java index 20c748592..f8c1f8fb9 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/impl/EscapeHexCellWriteHandler.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/handler/impl/EscapeHexCellWriteHandler.java @@ -23,6 +23,7 @@ import org.apache.fesod.sheet.event.NotRepeatExecutor; import org.apache.fesod.sheet.metadata.Head; import org.apache.fesod.sheet.metadata.data.WriteCellData; +import org.apache.fesod.sheet.util.XlsxEscapeUtils; import org.apache.fesod.sheet.write.handler.CellWriteHandler; import org.apache.fesod.sheet.write.metadata.holder.WriteSheetHolder; import org.apache.fesod.sheet.write.metadata.holder.WriteTableHolder; @@ -51,21 +52,6 @@ */ public class EscapeHexCellWriteHandler implements CellWriteHandler, NotRepeatExecutor { - // ASCII hex digits only. Not Character.digit(c, 16), which also accepts non-ASCII - // digits such as U+0663 that OOXML never uses. - private static final boolean[] HEX_TABLE = new boolean[128]; - - static { - for (char c = '0'; c <= '9'; c++) HEX_TABLE[c] = true; - for (char c = 'A'; c <= 'F'; c++) HEX_TABLE[c] = true; - for (char c = 'a'; c <= 'f'; c++) HEX_TABLE[c] = true; - } - - private static final String PREFIX = "_x"; - private static final String ESCAPED_PREFIX = "_x005F" + PREFIX; - private static final int PREFIX_LENGTH = PREFIX.length(); - private static final int HEX_DIGIT_COUNT = 4; - /** * Returns the fully qualified class name as the unique identity of this handler. *

@@ -90,67 +76,9 @@ public void afterCellDataConverted( if (cellData != null && cell instanceof SXSSFCell && CellDataTypeEnum.STRING.equals(cellData.getType())) { String originalString = cellData.getStringValue(); if (originalString != null) { - String escapedString = escapeHex(originalString); + String escapedString = XlsxEscapeUtils.escape(originalString); cellData.setStringValue(escapedString); } } } - - /** - * Replaces every _xHHHH_ sequence with _x005F_xHHHH_ to prevent POI from decoding them. - */ - private String escapeHex(String originalString) { - int length = originalString.length(); - - // Lazily allocated: stays null (no allocation) when no valid pattern is found - StringBuilder result = null; - int lastEnd = 0; - int searchStart = 0; - int patternIndex; - while ((patternIndex = originalString.indexOf(PREFIX, searchStart)) != -1) { - int hexStart = patternIndex + PREFIX_LENGTH; - int suffixIndex = hexStart + HEX_DIGIT_COUNT; - int patternEnd = suffixIndex + 1; - // Too few characters left for a full pattern, and any later match has even fewer - if (patternEnd > length) { - break; - } - - if (originalString.charAt(suffixIndex) == '_' && isHexDigits(originalString, hexStart)) { - if (result == null) { - result = new StringBuilder(length + 64); - } - // Append content since the previous match, then the escaped pattern - result.append(originalString, lastEnd, patternIndex); - result.append(ESCAPED_PREFIX); - result.append(originalString, hexStart, suffixIndex); - result.append('_'); - lastEnd = patternEnd; - searchStart = patternEnd; - } else { - searchStart = hexStart; - } - } - - // No valid patterns found - if (result == null) { - return originalString; - } - - result.append(originalString, lastEnd, length); - return result.toString(); - } - - /** - * Checks whether the four characters starting at {@code startIndex} are all ASCII hex digits. - */ - private static boolean isHexDigits(String str, int startIndex) { - for (int i = 0; i < HEX_DIGIT_COUNT; i++) { - char c = str.charAt(startIndex + i); - if (c >= 128 || !HEX_TABLE[c]) { - return false; - } - } - return true; - } } diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/write/handler/EscapeHexHeadAndFillWriteTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/write/handler/EscapeHexHeadAndFillWriteTest.java new file mode 100644 index 000000000..d402dbad9 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/write/handler/EscapeHexHeadAndFillWriteTest.java @@ -0,0 +1,94 @@ +/* + * 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.write.handler; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.support.ExcelTypeEnum; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.testkit.base.AbstractExcelTest; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The default XLSX write handler set includes {@code EscapeHexCellWriteHandler}, so literal {@code _xHHHH_} + * sequences survive a round trip wherever text reaches a cell: data, head and multi-variable fill cells alike. + * POI's reader decodes {@code _xHHHH_} to the character it names, so an unescaped literal comes back corrupted. + */ +@Tag(Tags.ROUND_TRIP) +class EscapeHexHeadAndFillWriteTest extends AbstractExcelTest { + + @Test + void headCellRoundTripsLiteralEscapeSequences() throws Exception { + File file = createTempFile("escape-hex-head", org.apache.fesod.sheet.testkit.enums.ExcelFormat.XLSX); + + List> head = new ArrayList<>(); + head.add(Collections.singletonList("_x0041_ col")); + List> data = new ArrayList<>(); + data.add(Collections.singletonList("v")); + FesodSheet.write(file).excelType(ExcelTypeEnum.XLSX).head(head).sheet().doWrite(data); + + Assertions.assertEquals("_x0041_ col", readCellValue(file, 0, 0)); + } + + @Test + void fillMultiVariableCellRoundTripsLiteralEscapeSequences() throws Exception { + File template = createTemplateWithPlaceholder(); + File file = createTempFile("escape-hex-fill", org.apache.fesod.sheet.testkit.enums.ExcelFormat.XLSX); + + Map map = new HashMap<>(); + map.put("a", "_x0041_ x"); + FesodSheet.write(file) + .excelType(ExcelTypeEnum.XLSX) + .withTemplate(template) + .sheet() + .doFill(map); + + Assertions.assertEquals("p _x0041_ x s", readCellValue(file, 0, 0)); + } + + private File createTemplateWithPlaceholder() throws Exception { + File template = + createTempFile("escape-hex-fill-template", org.apache.fesod.sheet.testkit.enums.ExcelFormat.XLSX); + try (Workbook workbook = new XSSFWorkbook()) { + workbook.createSheet("S").createRow(0).createCell(0).setCellValue("p {a} s"); + try (FileOutputStream out = new FileOutputStream(template)) { + workbook.write(out); + } + } + return template; + } + + private String readCellValue(File file, int rowIdx, int colIdx) throws Exception { + try (Workbook workbook = WorkbookFactory.create(file)) { + return workbook.getSheetAt(0).getRow(rowIdx).getCell(colIdx).getStringCellValue(); + } + } +}