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..b8c487b23 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 @@ -29,6 +29,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -61,11 +62,25 @@ import org.apache.fesod.sheet.write.metadata.fill.FillConfig; import org.apache.fesod.sheet.write.metadata.fill.FillWrapper; import org.apache.fesod.sheet.write.metadata.holder.WriteSheetHolder; +import org.apache.poi.hssf.usermodel.HSSFEvaluationWorkbook; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.formula.FormulaParseException; +import org.apache.poi.ss.formula.FormulaParser; +import org.apache.poi.ss.formula.FormulaParsingWorkbook; +import org.apache.poi.ss.formula.FormulaRenderer; +import org.apache.poi.ss.formula.FormulaRenderingWorkbook; +import org.apache.poi.ss.formula.FormulaShifter; +import org.apache.poi.ss.formula.FormulaType; +import org.apache.poi.ss.formula.ptg.Ptg; +import org.apache.poi.ss.formula.ptg.RefPtg; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFEvaluationWorkbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; /** * Fill the data into excel @@ -101,6 +116,10 @@ public class ExcelWriteFillExecutor extends AbstractExcelWriteExecutor { * Last index cache for collection fields */ private final Map> collectionLastIndexCache = MapUtils.newHashMap(); + /** + * Formula cells inside the collection template rows, carried over to the created rows + */ + private final Map> templateFormulaCellsCache = MapUtils.newHashMap(); private final Map relativeRowIndexMap = MapUtils.newHashMap(); /** @@ -144,7 +163,14 @@ public void fill(Object data, FillConfig fillConfig) { } Iterator iterator = collectionData.iterator(); if (WriteDirectionEnum.VERTICAL.equals(fillConfig.getDirection()) && fillConfig.getForceNewRow()) { - shiftRows(collectionData.size(), analysisCellList); + RowShiftSpec shiftSpec = shiftRows(collectionData.size(), analysisCellList); + if (shiftSpec != null) { + // POI shiftRows does not update formula references in rows outside the + // shifted range. Shift the cached formula text so copyFormulaCells + // translates the post-shift formula. The spec was computed from the + // same pre-shift state that Sheet.shiftRows consumed. + refreshFormulaCacheAfterShift(shiftSpec.startRow, shiftSpec.endRow, shiftSpec.amount); + } } while (iterator.hasNext()) { doFill(analysisCellList, iterator.next(), fillConfig, getRelativeRowIndex()); @@ -154,9 +180,9 @@ public void fill(Object data, FillConfig fillConfig) { } } - private void shiftRows(int size, List analysisCellList) { + private RowShiftSpec shiftRows(int size, List analysisCellList) { if (CollectionUtils.isEmpty(analysisCellList)) { - return; + return null; } int maxRowIndex = 0; Map collectionLastIndexMap = collectionLastIndexCache.get(currentUniqueDataFlag); @@ -177,7 +203,7 @@ private void shiftRows(int size, List analysisCellList) { Sheet cachedSheet = writeContext.writeSheetHolder().getCachedSheet(); int lastRowIndex = cachedSheet.getLastRowNum(); if (maxRowIndex >= lastRowIndex) { - return; + return null; } Sheet sheet = writeContext.writeSheetHolder().getCachedSheet(); int number = size; @@ -185,13 +211,17 @@ private void shiftRows(int size, List analysisCellList) { number--; } if (number <= 0) { - return; + return null; } - sheet.shiftRows(maxRowIndex + 1, lastRowIndex, number, true, false); + // Compute the spec from the pre-shift sheet state — both Sheet.shiftRows + // and refreshFormulaCacheAfterShift consume this same instance. + RowShiftSpec spec = new RowShiftSpec(maxRowIndex + 1, lastRowIndex, number); + sheet.shiftRows(spec.startRow, spec.endRow, spec.amount, true, false); // The current data is greater than unity rowindex increase increaseRowIndex(templateAnalysisCache, number, maxRowIndex); increaseRowIndex(templateCollectionAnalysisCache, number, maxRowIndex); + return spec; } private void increaseRowIndex( @@ -333,6 +363,12 @@ private void doFill( WriteHandlerUtils.afterCellDispose(cellWriteHandlerContext); } + // Formula cells in the collection template rows must reach every created row, + // otherwise a formula column next to the placeholders silently disappears. + if (relativeRowIndex != null) { + copyFormulaCells(fillConfig, relativeRowIndex); + } + // In the case of the fill line may be called many times if (rowWriteHandlerContext.getRow() != null) { WriteHandlerUtils.afterRowDispose(rowWriteHandlerContext); @@ -492,6 +528,10 @@ private void checkRowHeight(AnalysisCell analysisCell, FillConfig fillConfig, bo private List readTemplateData(Map> analysisCache) { List analysisCellList = analysisCache.get(currentUniqueDataFlag); if (analysisCellList != null) { + // The full scan below also fills the caches of every other collection on the sheet, so + // each later collection reaches this early return. Collect the formula cells here too, + // otherwise only the first collection keeps them. + collectFormulaTemplateCells(writeContext.writeSheetHolder().getCachedSheet()); return analysisCellList; } Sheet sheet = writeContext.writeSheetHolder().getCachedSheet(); @@ -513,9 +553,237 @@ private List readTemplateData(Map collectionAnalysisCellList = templateCollectionAnalysisCache.get(currentUniqueDataFlag); + if (CollectionUtils.isEmpty(collectionAnalysisCellList)) { + return; + } + if (templateFormulaCellsCache.containsKey(currentUniqueDataFlag)) { + // Already collected for this collection (e.g. the analysis-cache early return in + // readTemplateData now also lands here); never append the same formula twice. + return; + } + Set collectionRowIndexSet = new HashSet<>(); + // Columns occupied by the collection placeholders must be excluded: when the + // collection fills horizontally those cells carry data, not formulas. + Set placeholderColumnSet = new HashSet<>(); + for (AnalysisCell analysisCell : collectionAnalysisCellList) { + collectionRowIndexSet.add(analysisCell.getRowIndex()); + placeholderColumnSet.add(analysisCell.getColumnIndex()); + } + List formulaCellList = + templateFormulaCellsCache.computeIfAbsent(currentUniqueDataFlag, key -> ListUtils.newArrayList()); + for (Integer rowIndex : collectionRowIndexSet) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + continue; + } + for (int j = 0; j < row.getLastCellNum(); j++) { + if (placeholderColumnSet.contains(j)) { + // This column holds a data placeholder; skip to avoid a collision + // when the collection fills horizontally and data overwrites the formula. + continue; + } + Cell cell = row.getCell(j); + if (cell == null || !CellType.FORMULA.equals(cell.getCellType())) { + continue; + } + formulaCellList.add(new TemplateFormulaCell(rowIndex, j, cell.getCellFormula())); + } + } + } + + /** + * After shiftRows has moved rows in the live sheet, POI updates formula references in + * shifted rows. The cached formula text is pre-shift; refresh it so copyFormulaCells + * translates the shifted formula rather than the stale original. + */ + /** + * After {@code Sheet.shiftRows} has moved rows, POI updates formulas in the shifted + * rows but not in rows outside the shifted range (e.g. the collection template row). + * Refresh the cached formula text with {@link FormulaShifter} so {@code copyFormulaCells} + * translates the post-shift formula. Only called when rows were actually shifted. + */ + private void refreshFormulaCacheAfterShift(int shiftStartRow, int shiftEndRow, int shiftAmount) { + List formulaCellList = templateFormulaCellsCache.get(currentUniqueDataFlag); + if (CollectionUtils.isEmpty(formulaCellList)) { + return; + } + Workbook templateWorkbook = writeContext.writeWorkbookHolder().getCachedWorkbook(); + FormulaParsingWorkbook parsingWorkbook = createFormulaParsingWorkbook(templateWorkbook); + if (parsingWorkbook == null) { + return; + } + Sheet cachedSheet = writeContext.writeSheetHolder().getCachedSheet(); + String sheetName = cachedSheet.getSheetName(); + int externSheetIndex = parsingWorkbook.getExternalSheetIndex(sheetName); + for (TemplateFormulaCell tcf : formulaCellList) { + try { + Ptg[] ptgs = FormulaParser.parse(tcf.getFormula(), parsingWorkbook, FormulaType.CELL, externSheetIndex); + FormulaShifter shifter = FormulaShifter.createForRowShift( + externSheetIndex, + sheetName, + shiftStartRow, + shiftEndRow, + shiftAmount, + parsingWorkbook.getSpreadsheetVersion()); + shifter.adjustFormula(ptgs, externSheetIndex); + tcf.setFormula(FormulaRenderer.toFormulaString((FormulaRenderingWorkbook) parsingWorkbook, ptgs)); + } catch (Exception e) { + // Cannot parse/shift; keep the pre-shift formula so copyFormulaCells + // can still translate it relative to the target row. + } + } + } + + private void copyFormulaCells(FillConfig fillConfig, Integer relativeRowIndex) { + if (relativeRowIndex == null || relativeRowIndex == 0) { + // The first item fills the original template row, which already holds the formulas. + return; + } + List formulaCellList = templateFormulaCellsCache.get(currentUniqueDataFlag); + if (CollectionUtils.isEmpty(formulaCellList)) { + return; + } + boolean vertical = WriteDirectionEnum.VERTICAL.equals(fillConfig.getDirection()); + Sheet sheet = writeContext.writeSheetHolder().getSheet(); + Sheet cachedSheet = writeContext.writeSheetHolder().getCachedSheet(); + for (TemplateFormulaCell templateFormulaCell : formulaCellList) { + int targetRowIndex = + vertical ? templateFormulaCell.getRowIndex() + relativeRowIndex : templateFormulaCell.getRowIndex(); + int targetColumnIndex = vertical + ? templateFormulaCell.getColumnIndex() + : templateFormulaCell.getColumnIndex() + relativeRowIndex; + Row targetRow = getRowForFormulaCopy(sheet, cachedSheet, targetRowIndex); + if (targetRow == null) { + continue; + } + Cell targetCell = targetRow.getCell(targetColumnIndex); + if (targetCell != null && !CellType.BLANK.equals(targetCell.getCellType())) { + // The destination cell already holds a value (not a formula from this + // fill, but user data from the template or a previous collection area). + // Skip to avoid overwriting it with a copied formula. + continue; + } + if (targetCell == null) { + targetCell = targetRow.createCell(targetColumnIndex); + } + if (fillConfig.getAutoStyle()) { + Row templateRow = cachedSheet.getRow(templateFormulaCell.getRowIndex()); + Cell templateCell = + templateRow == null ? null : templateRow.getCell(templateFormulaCell.getColumnIndex()); + if (templateCell != null) { + targetCell.setCellStyle(templateCell.getCellStyle()); + } + } + String translatedFormula = + translateFormula(cachedSheet, templateFormulaCell, targetRowIndex, targetColumnIndex, vertical); + if (translatedFormula == null) { + continue; + } + try { + targetCell.setCellFormula(translatedFormula); + } catch (FormulaParseException e) { + // Never let a malformed formula break the fill; leave the cell blank instead. + } + } + } + + private Row getRowForFormulaCopy(Sheet sheet, Sheet cachedSheet, int targetRowIndex) { + Row row = sheet.getRow(targetRowIndex); + if (row == null) { + row = cachedSheet.getRow(targetRowIndex); + } + if (row == null) { + try { + row = sheet.createRow(targetRowIndex); + } catch (IllegalArgumentException e) { + row = cachedSheet.createRow(targetRowIndex); + } + } + return row; + } + + /** + * Translate the template formula so its references follow the copied row/column, the way a + * spreadsheet copies the row down: relative references move, absolute ones stay anchored. + */ + private String translateFormula( + Sheet cachedSheet, + TemplateFormulaCell templateFormulaCell, + int targetRowIndex, + int targetColumnIndex, + boolean vertical) { + String formula = templateFormulaCell.getFormula(); + Workbook templateWorkbook = writeContext.writeWorkbookHolder().getCachedWorkbook(); + FormulaParsingWorkbook parsingWorkbook = createFormulaParsingWorkbook(templateWorkbook); + if (parsingWorkbook == null) { + return null; + } + try { + String sheetName = cachedSheet.getSheetName(); + int externSheetIndex = parsingWorkbook.getExternalSheetIndex(sheetName); + Ptg[] ptgs = FormulaParser.parse(formula, parsingWorkbook, FormulaType.CELL, externSheetIndex); + Map anchoredRefs = new IdentityHashMap<>(); + for (Ptg ptg : ptgs) { + if (ptg instanceof RefPtg) { + RefPtg refPtg = (RefPtg) ptg; + if (vertical ? !refPtg.isRowRelative() : !refPtg.isColRelative()) { + anchoredRefs.put(refPtg, vertical ? refPtg.getRow() : refPtg.getColumn()); + } + } + } + FormulaShifter formulaShifter = vertical + ? FormulaShifter.createForRowCopy( + externSheetIndex, + sheetName, + templateFormulaCell.getRowIndex(), + templateFormulaCell.getRowIndex(), + targetRowIndex - templateFormulaCell.getRowIndex(), + parsingWorkbook.getSpreadsheetVersion()) + : FormulaShifter.createForColumnCopy( + externSheetIndex, + sheetName, + templateFormulaCell.getColumnIndex(), + templateFormulaCell.getColumnIndex(), + targetColumnIndex - templateFormulaCell.getColumnIndex(), + parsingWorkbook.getSpreadsheetVersion()); + formulaShifter.adjustFormula(ptgs, externSheetIndex); + for (Map.Entry entry : anchoredRefs.entrySet()) { + RefPtg refPtg = (RefPtg) entry.getKey(); + if (vertical) { + refPtg.setRow(entry.getValue()); + } else { + refPtg.setColumn(entry.getValue()); + } + } + return FormulaRenderer.toFormulaString((FormulaRenderingWorkbook) parsingWorkbook, ptgs); + } catch (Exception e) { + // Cannot translate the formula to the target cell; returning the original + // would silently point at wrong cells. Return null so copyFormulaCells + // skips it rather than writing an incorrect formula. + return null; + } + } + + private FormulaParsingWorkbook createFormulaParsingWorkbook(Workbook templateWorkbook) { + if (templateWorkbook instanceof XSSFWorkbook) { + return XSSFEvaluationWorkbook.create((XSSFWorkbook) templateWorkbook); + } + if (templateWorkbook instanceof HSSFWorkbook) { + return HSSFEvaluationWorkbook.create((HSSFWorkbook) templateWorkbook); + } + return null; + } + /** * To prepare data * @@ -670,6 +938,15 @@ private UniqueDataFlagKey uniqueDataFlag(WriteSheetHolder writeSheetHolder, Stri return new UniqueDataFlagKey(writeSheetHolder.getSheetNo(), writeSheetHolder.getSheetName(), wrapperName); } + @Getter + @Setter + @AllArgsConstructor + private static class TemplateFormulaCell { + private Integer rowIndex; + private Integer columnIndex; + private String formula; + } + @Getter @Setter @EqualsAndHashCode @@ -679,4 +956,22 @@ public static class UniqueDataFlagKey { private String sheetName; private String wrapperName; } + + /** + * Immutable snapshot of the row-shift parameters computed before + * {@code Sheet.shiftRows} mutates the sheet. Both the worksheet shift + * and the cached-formula refresh consume the same instance so the two + * operations can never disagree on the affected range. + */ + private static final class RowShiftSpec { + final int startRow; + final int endRow; + final int amount; + + RowShiftSpec(int startRow, int endRow, int amount) { + this.startRow = startRow; + this.endRow = endRow; + this.amount = amount; + } + } } diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/fill/FillWithFormulaInCreatedRowsTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/fill/FillWithFormulaInCreatedRowsTest.java new file mode 100644 index 000000000..03dac7e96 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/fill/FillWithFormulaInCreatedRowsTest.java @@ -0,0 +1,420 @@ +/* + * 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.fill; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fesod.sheet.ExcelWriter; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.enums.WriteDirectionEnum; +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.write.metadata.WriteSheet; +import org.apache.fesod.sheet.write.metadata.fill.FillConfig; +import org.apache.fesod.sheet.write.metadata.fill.FillWrapper; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +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; + +@Tag(Tags.ROUND_TRIP) +public class FillWithFormulaInCreatedRowsTest extends AbstractExcelTest { + + private File createTemplate() throws Exception { + File template = createTempFile(ExcelFormat.XLSX); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Sheet0"); + Row headRow = sheet.createRow(0); + headRow.createCell(0).setCellValue("item"); + headRow.createCell(1).setCellValue("double"); + headRow.createCell(2).setCellValue("abs"); + Row templateRow = sheet.createRow(1); + templateRow.createCell(0).setCellValue("{data.name}"); + templateRow.createCell(1).setCellFormula("A2*2"); + templateRow.createCell(2).setCellFormula("$A$2*10"); + try (FileOutputStream out = new FileOutputStream(template)) { + workbook.write(out); + } + } + return template; + } + + private List> buildData(int size) { + List> list = new ArrayList<>(); + for (int i = 1; i <= size; i++) { + Map row = new HashMap<>(); + row.put("name", "n" + i); + list.add(row); + } + return list; + } + + private static String formulaOf(Sheet sheet, int rowIndex, int columnIndex) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + return null; + } + Cell cell = row.getCell(columnIndex); + if (cell == null) { + return null; + } + return cell.getCellType() == CellType.FORMULA ? cell.getCellFormula() : null; + } + + @Test + void fillListKeepsFormulasInCreatedRows() throws Exception { + File template = createTemplate(); + File outFile = createTempFile(ExcelFormat.XLSX); + + FesodSheet.write(outFile) + .withTemplate(template) + .sheet() + .doFill( + new FillWrapper("data", buildData(3)), + FillConfig.builder().forceNewRow(true).build()); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(outFile))) { + Sheet sheet = workbook.getSheetAt(0); + // Values are filled for every created row. + for (int i = 1; i <= 3; i++) { + Assertions.assertEquals("n" + i, sheet.getRow(i).getCell(0).getStringCellValue()); + } + // The original row keeps its template formulas; every created row carries a + // translated copy: relative references follow the copied row, absolute ones stay. + Assertions.assertEquals("A2*2", formulaOf(sheet, 1, 1)); + Assertions.assertEquals("A3*2", formulaOf(sheet, 2, 1)); + Assertions.assertEquals("A4*2", formulaOf(sheet, 3, 1)); + Assertions.assertEquals("$A$2*10", formulaOf(sheet, 1, 2)); + Assertions.assertEquals("$A$2*10", formulaOf(sheet, 2, 2)); + Assertions.assertEquals("$A$2*10", formulaOf(sheet, 3, 2)); + } + } + + @Test + void fillListWithoutForceNewRowKeepsFormulasInCreatedRows() throws Exception { + File template = createTemplate(); + File outFile = createTempFile(ExcelFormat.XLSX); + + FesodSheet.write(outFile).withTemplate(template).sheet().doFill(new FillWrapper("data", buildData(3))); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(outFile))) { + Sheet sheet = workbook.getSheetAt(0); + Assertions.assertEquals("A2*2", formulaOf(sheet, 1, 1)); + Assertions.assertEquals("A3*2", formulaOf(sheet, 2, 1)); + Assertions.assertEquals("A4*2", formulaOf(sheet, 3, 1)); + Assertions.assertEquals("$A$2*10", formulaOf(sheet, 2, 2)); + } + } + + /** + * Two collection areas on one sheet: {@code other} sits above {@code data}, so {@code data} + * is filled first (full template scan) and {@code other} is filled second (its analysis data + * is already cached). Formulas next to both collections must survive. + */ + private File createTwoCollectionTemplate() throws Exception { + File template = createTempFile(ExcelFormat.XLSX); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Sheet0"); + Row headRow = sheet.createRow(0); + headRow.createCell(0).setCellValue("item"); + headRow.createCell(1).setCellValue("double"); + Row otherRow = sheet.createRow(1); + otherRow.createCell(0).setCellValue("{other.name}"); + otherRow.createCell(1).setCellFormula("A2*2"); + Row separator = sheet.createRow(2); + separator.createCell(0).setCellValue("sep"); + Row dataRow = sheet.createRow(3); + dataRow.createCell(0).setCellValue("{data.name}"); + dataRow.createCell(1).setCellFormula("A4*2"); + try (FileOutputStream out = new FileOutputStream(template)) { + workbook.write(out); + } + } + return template; + } + + @Test + void fillKeepsFormulasForEveryCollectionArea() throws Exception { + File template = createTwoCollectionTemplate(); + File outFile = createTempFile(ExcelFormat.XLSX); + + // One writer, two fills: builder.doFill() finishes the writer after every call, so two + // collection areas need the lower-level fill API. + ExcelWriter writer = FesodSheet.write(outFile).withTemplate(template).build(); + WriteSheet writeSheet = new WriteSheet(); + writer.fill(new FillWrapper("data", buildData(2)), writeSheet); + writer.fill(new FillWrapper("other", buildData(2)), writeSheet); + writer.finish(); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(outFile))) { + Sheet sheet = workbook.getSheetAt(0); + // The collection filled first keeps its formulas (existing behaviour). + Assertions.assertEquals("A4*2", formulaOf(sheet, 3, 1)); + Assertions.assertEquals("A5*2", formulaOf(sheet, 4, 1)); + // The collection filled second reaches the cached analysis data; its formulas must be + // collected on that path too. + Assertions.assertEquals("A2*2", formulaOf(sheet, 1, 1)); + Assertions.assertEquals("A3*2", formulaOf(sheet, 2, 1)); + } + } + + /** + * Horizontal fill: the formula column overlaps a data column — the formula must not + * overwrite the data cell. + */ + @Test + void horizontalFillDoesNotCollideWithDataColumn() throws Exception { + File template = createTempFile(ExcelFormat.XLSX); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Sheet0"); + Row templateRow = sheet.createRow(0); + templateRow.createCell(0).setCellValue("{data.name}"); + templateRow.createCell(1).setCellFormula("A1*2"); + try (FileOutputStream out = new FileOutputStream(template)) { + workbook.write(out); + } + } + File outFile = createTempFile(ExcelFormat.XLSX); + + ExcelWriter writer = FesodSheet.write(outFile).withTemplate(template).build(); + WriteSheet writeSheet = new WriteSheet(); + writer.fill( + new FillWrapper("data", buildData(3)), + FillConfig.builder().direction(WriteDirectionEnum.HORIZONTAL).build(), + writeSheet); + writer.finish(); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(outFile))) { + Sheet sheet = workbook.getSheetAt(0); + // Data fills all three columns in the template row. + Assertions.assertEquals("n1", sheet.getRow(0).getCell(0).getStringCellValue()); + Assertions.assertEquals("n2", sheet.getRow(0).getCell(1).getStringCellValue()); + Assertions.assertEquals("n3", sheet.getRow(0).getCell(2).getStringCellValue()); + } + } + + /** + * forceNewRow with a formula referencing a row below the collection area: after + * shiftRows the cached formula text must be refreshed so copyFormulaCells translates + * the shifted formula, not the pre-shift original. + */ + @Test + void forceNewRowUpdatesFormulaCacheAfterShift() throws Exception { + File template = createTempFile(ExcelFormat.XLSX); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Sheet0"); + Row headRow = sheet.createRow(0); + headRow.createCell(0).setCellValue("item"); + headRow.createCell(1).setCellValue("double"); + Row templateRow = sheet.createRow(1); + templateRow.createCell(0).setCellValue("{data.name}"); + templateRow.createCell(1).setCellFormula("A4*2"); + try (FileOutputStream out = new FileOutputStream(template)) { + workbook.write(out); + } + } + File outFile = createTempFile(ExcelFormat.XLSX); + + FesodSheet.write(outFile) + .withTemplate(template) + .sheet() + .doFill( + new FillWrapper("data", buildData(2)), + FillConfig.builder().forceNewRow(true).build()); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(outFile))) { + Sheet sheet = workbook.getSheetAt(0); + // The template has only rows 0-1, so shiftRows returns early and the formula + // cache is NOT refreshed. Copying A4*2 to row 2 should give A5*2. + Assertions.assertEquals("A5*2", formulaOf(sheet, 2, 1)); + } + } + + /** + * forceNewRow with a populated row below the collection: shiftRows actually moves + * rows, so the cached formula text must be refreshed before copying. + */ + @Test + void forceNewRowShiftsFormulaCacheWhenRowsMove() throws Exception { + File template = createTempFile(ExcelFormat.XLSX); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Sheet0"); + Row headRow = sheet.createRow(0); + headRow.createCell(0).setCellValue("item"); + headRow.createCell(1).setCellValue("double"); + Row templateRow = sheet.createRow(1); + templateRow.createCell(0).setCellValue("{data.name}"); + templateRow.createCell(1).setCellFormula("A4*2"); + // A populated row below the collection so shiftRows has something to move. + Row belowRow = sheet.createRow(3); + belowRow.createCell(0).setCellValue("below"); + belowRow.createCell(1).setCellFormula("A4*10"); + try (FileOutputStream out = new FileOutputStream(template)) { + workbook.write(out); + } + } + File outFile = createTempFile(ExcelFormat.XLSX); + + FesodSheet.write(outFile) + .withTemplate(template) + .sheet() + .doFill( + new FillWrapper("data", buildData(2)), + FillConfig.builder().forceNewRow(true).build()); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(outFile))) { + Sheet sheet = workbook.getSheetAt(0); + // shiftRows(2, ...) moves row 3 down by 1 (number = size-1 = 1). + // Template formula A4*2 at row 1 is outside the shifted range (rows 2+), + // but its reference to A4 (row 3) moves to row 4 -> A5*2 after refresh. + // Created row at row 2: translates A5*2 -> A6*2. + Assertions.assertEquals("A5*2", formulaOf(sheet, 1, 1)); + Assertions.assertEquals("A6*2", formulaOf(sheet, 2, 1)); + } + } + + /** + * Horizontal fill must not overwrite cells that already hold a value in the + * non-data columns. The reviewer's exact case: {data.name} in A1, =A1*2 in C1, + * and "Keep" in D1. After filling two items horizontally, D1 must stay "Keep". + */ + @Test + void horizontalFillPreservesExistingCellValues() throws Exception { + File template = createTempFile(ExcelFormat.XLSX); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Sheet0"); + Row row = sheet.createRow(0); + row.createCell(0).setCellValue("{data.name}"); + row.createCell(1).setCellFormula("A1*2"); + row.createCell(2).setCellValue("Keep"); + try (FileOutputStream out = new FileOutputStream(template)) { + workbook.write(out); + } + } + File outFile = createTempFile(ExcelFormat.XLSX); + + ExcelWriter writer = FesodSheet.write(outFile).withTemplate(template).build(); + WriteSheet writeSheet = new WriteSheet(); + writer.fill( + new FillWrapper("data", buildData(2)), + FillConfig.builder().direction(WriteDirectionEnum.HORIZONTAL).build(), + writeSheet); + writer.finish(); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(outFile))) { + Sheet sheet = workbook.getSheetAt(0); + // Data fills columns 0-1 in the template row. + Assertions.assertEquals("n1", sheet.getRow(0).getCell(0).getStringCellValue()); + Assertions.assertEquals("n2", sheet.getRow(0).getCell(1).getStringCellValue()); + // Column 2 holds the original value, not a copied formula. + Assertions.assertEquals("Keep", sheet.getRow(0).getCell(2).getStringCellValue()); + } + } + + /** + * save-and-reopen case from skytin1004's fifth review: + * Template: A2={data.name}, B2==A6*2, A4="footer". + * Fill 3 items with forceNewRow(true). After save+reopen: + * - B2 formula should reference the shifted row (not #REF!) + * - Copied formulas should follow the same row-shift as the source + * - A reference that falls inside the newly expanded range should be valid + */ + @Test + void forceNewRowCopiedFormulasMatchSourceShift() throws Exception { + File template = createTempFile(ExcelFormat.XLSX); + try (XSSFWorkbook workbook = new XSSFWorkbook()) { + Sheet sheet = workbook.createSheet("Sheet0"); + Row headRow = sheet.createRow(0); + headRow.createCell(0).setCellValue("item"); + headRow.createCell(1).setCellValue("double"); + Row templateRow = sheet.createRow(1); + templateRow.createCell(0).setCellValue("{data.name}"); + templateRow.createCell(1).setCellFormula("A5*2"); + // A populated row below the collection so shiftRows has something to move. + Row belowRow = sheet.createRow(4); + belowRow.createCell(0).setCellValue("footer"); + belowRow.createCell(1).setCellFormula("A5*10"); + try (FileOutputStream out = new FileOutputStream(template)) { + workbook.write(out); + } + } + File outFile = createTempFile(ExcelFormat.XLSX); + + FesodSheet.write(outFile) + .withTemplate(template) + .sheet() + .doFill( + new FillWrapper("data", buildData(3)), + FillConfig.builder().forceNewRow(true).build()); + + // Reopen to verify what POI actually wrote to the file. + try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(outFile))) { + Sheet sheet = workbook.getSheetAt(0); + // The original formula in B2 (row 1, col 1) was A5*2 referencing row 4. + // After shiftRows(3, ...) inserts 2 rows, row 4 becomes row 6. + // The source formula should now reference A7*2 (row 6 shifted). + String sourceFormula = formulaOf(sheet, 1, 1); + Assertions.assertNotNull(sourceFormula, "Source formula must not be null"); + Assertions.assertFalse( + sourceFormula.contains("#REF!"), "Source formula must not contain #REF!: " + sourceFormula); + // The copied formulas at rows 2 and 3 should follow the same shift. + String copy1 = formulaOf(sheet, 2, 1); + String copy2 = formulaOf(sheet, 3, 1); + Assertions.assertNotNull(copy1, "First copy formula must not be null"); + Assertions.assertNotNull(copy2, "Second copy formula must not be null"); + Assertions.assertFalse(copy1.contains("#REF!"), "First copy must not contain #REF!: " + copy1); + Assertions.assertFalse(copy2.contains("#REF!"), "Second copy must not contain #REF!: " + copy2); + // Verify consistency: the relative offset between consecutive rows must be 1, + // meaning every copied formula is exactly one row below the previous one. + // The source formula at row 1 is the shifted original; copies at rows 2 and 3 + // are translated copies with +1 row each step. + String row1 = formulaOf(sheet, 1, 1); + String row2 = formulaOf(sheet, 2, 1); + String row3 = formulaOf(sheet, 3, 1); + // Row offsets must be monotonic and consistent: row2 = row1 + 1, row3 = row2 + 1. + // Verify by checking the row numbers in the formulas increase by 1 each row. + java.util.regex.Matcher m1 = + java.util.regex.Pattern.compile("A(\\d+)").matcher(row1); + java.util.regex.Matcher m2 = + java.util.regex.Pattern.compile("A(\\d+)").matcher(row2); + java.util.regex.Matcher m3 = + java.util.regex.Pattern.compile("A(\\d+)").matcher(row3); + if (m1.find() && m2.find() && m3.find()) { + int r1 = Integer.parseInt(m1.group(1)); + int r2 = Integer.parseInt(m2.group(1)); + int r3 = Integer.parseInt(m3.group(1)); + Assertions.assertEquals(1, r2 - r1, "Row offset between source and first copy must be 1"); + Assertions.assertEquals(1, r3 - r2, "Row offset between first and second copy must be 1"); + } else { + Assertions.fail("Could not parse row numbers from formulas: " + row1 + ", " + row2 + ", " + row3); + } + } + } +}