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(); + } + } +}