Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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.enums;

/**
* Strategy for handling merged regions during loop filling.
*/
public enum FillMergeStrategy {

/**
* Merged regions in the fill template will NOT be handing.
*/
NONE,

/**
* Automatically handling merged regions base on fill template.
*/
AUTO,

/**
* Automatically handling merged regions and unify the style using anchor cells base on fill template.
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JavaDoc/comment uses “handing” where “handling” is intended (e.g., “Merged regions … will NOT be handing”, “base on”). Please fix wording to avoid confusion in a public enum’s documentation.

Suggested change
* Merged regions in the fill template will NOT be handing.
*/
NONE,
/**
* Automatically handling merged regions base on fill template.
*/
AUTO,
/**
* Automatically handling merged regions and unify the style using anchor cells base on fill template.
* Merged regions in the fill template will NOT be handled.
*/
NONE,
/**
* Automatically handling merged regions based on the fill template.
*/
AUTO,
/**
* Automatically handling merged regions and unifying the style using anchor cells based on the fill template.

Copilot uses AI. Check for mistakes.
* <p />
* <b>Warning: Too many CellStyle instances may lead to performance issues and can exceed number of cell styles
* limits (64000 for .xlsx and 4000 for .xls).</b>
*/
MERGE_CELL_STYLE
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IntSummaryStatistics;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
Expand All @@ -39,6 +41,7 @@
import org.apache.fesod.common.util.StringUtils;
import org.apache.fesod.sheet.context.WriteContext;
import org.apache.fesod.sheet.enums.CellDataTypeEnum;
import org.apache.fesod.sheet.enums.FillMergeStrategy;
import org.apache.fesod.sheet.enums.WriteDirectionEnum;
import org.apache.fesod.sheet.enums.WriteTemplateAnalysisCellTypeEnum;
import org.apache.fesod.sheet.exception.ExcelGenerateException;
Expand All @@ -60,6 +63,7 @@
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.util.CellRangeAddress;

/**
* Fill the data into excel
Expand Down Expand Up @@ -137,18 +141,116 @@ public void fill(Object data, FillConfig fillConfig) {
return;
}
Iterator<?> iterator = collectionData.iterator();
int rowSpan = calculateRowSpan(analysisCellList);
if (WriteDirectionEnum.VERTICAL.equals(fillConfig.getDirection()) && fillConfig.getForceNewRow()) {
shiftRows(collectionData.size(), analysisCellList);
shiftRows(collectionData.size(), rowSpan, analysisCellList);
}
while (iterator.hasNext()) {
doFill(analysisCellList, iterator.next(), fillConfig, getRelativeRowIndex());
doFill(analysisCellList, iterator.next(), fillConfig, getRelativeRowIndex(), rowSpan);
}
} else {
doFill(readTemplateData(templateAnalysisCache), realData, fillConfig, null);
doFill(readTemplateData(templateAnalysisCache), realData, fillConfig, null, 0);
}
}

private void shiftRows(int size, List<AnalysisCell> analysisCellList) {
private void addMergedRegionIfNecessary(List<AnalysisCell> analysisCellList, FillConfig fillConfig) {
FillMergeStrategy mergeStrategy = fillConfig.getMergeStrategy();
if (FillMergeStrategy.NONE.equals(mergeStrategy)) {
return;
}

Set<CellRangeAddress> dataRowMergeRegions = determineMergedRegionsForRow(analysisCellList, fillConfig);
if (CollectionUtils.isEmpty(dataRowMergeRegions)) {
return;
}

Sheet sheet = writeContext.writeSheetHolder().getSheet();

// Unify the style using anchor cells
if (FillMergeStrategy.MERGE_CELL_STYLE.equals(mergeStrategy)) {
Sheet cachedSheet = writeContext.writeSheetHolder().getCachedSheet();
Map<CellCoordinate, Set<CellCoordinate>> cellCoordinateMap = indexMergedRegionsMap(dataRowMergeRegions);

for (Map.Entry<CellCoordinate, Set<CellCoordinate>> entry : cellCoordinateMap.entrySet()) {
CellCoordinate anchor = entry.getKey();
CellStyle anchorStyle =
anchor.getOrCreateCell(sheet, cachedSheet).getCellStyle();

for (CellCoordinate mergedCell : entry.getValue()) {
Cell cell = mergedCell.getOrCreateCell(sheet, cachedSheet);
cell.setCellStyle(anchorStyle);
}
}
}

// Merge cells
for (CellRangeAddress range : dataRowMergeRegions) {
sheet.addMergedRegionUnsafe(range.copy());
Comment on lines +192 to +194
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current merge replication will also re-add the template’s existing merged regions for the first loop item (offset 0), which can create duplicate merged regions in the sheet. It would be safer to skip adding merges when the computed offset is 0 and the merged region already exists, or de-duplicate against existing merged regions before calling addMergedRegionUnsafe.

Suggested change
// Merge cells
for (CellRangeAddress range : dataRowMergeRegions) {
sheet.addMergedRegionUnsafe(range.copy());
// Merge cells
// Avoid adding duplicate merged regions by tracking existing ones
Set<String> existingMergedRegionKeys = new HashSet<>();
for (CellRangeAddress existingRange : sheet.getMergedRegions()) {
String key = existingRange.getFirstRow()
+ ":" + existingRange.getLastRow()
+ ":" + existingRange.getFirstColumn()
+ ":" + existingRange.getLastColumn();
existingMergedRegionKeys.add(key);
}
for (CellRangeAddress range : dataRowMergeRegions) {
String key = range.getFirstRow()
+ ":" + range.getLastRow()
+ ":" + range.getFirstColumn()
+ ":" + range.getLastColumn();
if (existingMergedRegionKeys.contains(key)) {
continue;
}
sheet.addMergedRegionUnsafe(range.copy());
existingMergedRegionKeys.add(key);

Copilot uses AI. Check for mistakes.
}
}

private Set<CellRangeAddress> determineMergedRegionsForRow(List<AnalysisCell> cells, FillConfig fillConfig) {
if (CollectionUtils.isEmpty(cells)
|| !WriteDirectionEnum.VERTICAL.equals(fillConfig.getDirection())
|| FillMergeStrategy.NONE.equals(fillConfig.getMergeStrategy())) {
return Collections.emptySet();
}

Sheet sheet = writeContext.writeSheetHolder().getSheet();
if (sheet.getNumMergedRegions() == 0) {
return Collections.emptySet();
}

Set<CellRangeAddress> absoluteRegions = new HashSet<>();
Map<AnalysisCell, Integer> collectionLastIndexMap = collectionLastIndexCache.get(currentUniqueDataFlag);

for (AnalysisCell cell : cells) {
for (CellRangeAddress range : sheet.getMergedRegions()) {
if (range.isInRange(cell.getRowIndex(), cell.getColumnIndex())) {
Comment on lines +216 to +224
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

determineMergedRegionsForRow only considers merged regions that intersect cells containing template variables (analysisCellList). Any merged regions within the template row-span that do not cover a placeholder cell will never be replicated, which seems to conflict with the docs/PR description that say the template row’s merge structure is replicated. If the intent is to replicate all merges in the loop block, consider scanning merged regions by row-span bounds rather than by placeholder cells.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

@bengbengbalabalabeng bengbengbalabalabeng Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected PR description

int firstRow = collectionLastIndexMap.get(cell);
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

determineMergedRegionsForRow assumes collectionLastIndexMap is non-null, but addMergedRegionIfNecessary is invoked for non-collection fills as well (the else branch calls doFill(..., rowSpan=0)). If a caller sets mergeStrategy != NONE for a non-collection fill, collectionLastIndexCache.get(currentUniqueDataFlag) will be null and this will throw an NPE. Please guard by returning an empty set when collectionLastIndexMap is null (and/or only applying merge strategies when filling collection data / analysisCell.getCellType() == COLLECTION).

Suggested change
for (AnalysisCell cell : cells) {
for (CellRangeAddress range : sheet.getMergedRegions()) {
if (range.isInRange(cell.getRowIndex(), cell.getColumnIndex())) {
int firstRow = collectionLastIndexMap.get(cell);
if (collectionLastIndexMap == null || collectionLastIndexMap.isEmpty()) {
return Collections.emptySet();
}
for (AnalysisCell cell : cells) {
Integer firstRow = collectionLastIndexMap.get(cell);
if (firstRow == null) {
// No recorded last index for this cell; skip to avoid NPE.
continue;
}
for (CellRangeAddress range : sheet.getMergedRegions()) {
if (range.isInRange(cell.getRowIndex(), cell.getColumnIndex())) {

Copilot uses AI. Check for mistakes.
int lastRow = firstRow + (range.getLastRow() - range.getFirstRow());
Comment on lines +223 to +225
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

determineMergedRegionsForRow calculates the replicated merged range using firstRow = collectionLastIndexMap.get(cell). This only works if the template variable cell is on the merged region’s first row; if a placeholder exists in any non-anchor cell inside a merged region, the computed firstRow/lastRow will be wrong and can create overlapping/incorrect merges. Consider computing a row offset (collectionLastIndexMap.get(cell) - cell.getRowIndex()) and shifting the original range by that offset (and/or only using the merged region’s anchor cell) to generate the replicated CellRangeAddress.

Suggested change
for (CellRangeAddress range : sheet.getMergedRegions()) {
if (range.isInRange(cell.getRowIndex(), cell.getColumnIndex())) {
int firstRow = collectionLastIndexMap.get(cell);
int lastRow = firstRow + (range.getLastRow() - range.getFirstRow());
Integer mappedRowIndex = collectionLastIndexMap.get(cell);
if (mappedRowIndex == null) {
continue;
}
int rowOffset = mappedRowIndex - cell.getRowIndex();
for (CellRangeAddress range : sheet.getMergedRegions()) {
if (range.isInRange(cell.getRowIndex(), cell.getColumnIndex())) {
int firstRow = range.getFirstRow() + rowOffset;
int lastRow = range.getLastRow() + rowOffset;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected PR description.

absoluteRegions.add(
new CellRangeAddress(firstRow, lastRow, range.getFirstColumn(), range.getLastColumn()));
}
}
}
return Collections.unmodifiableSet(absoluteRegions);
}

private Map<CellCoordinate, Set<CellCoordinate>> indexMergedRegionsMap(Set<CellRangeAddress> mergedRegions) {
Map<CellCoordinate, Set<CellCoordinate>> result = new HashMap<>();
for (CellRangeAddress range : mergedRegions) {
int firstRow = range.getFirstRow();
int firstCol = range.getFirstColumn();

// Anchor cell -> In merged cells
Set<CellCoordinate> cells =
result.computeIfAbsent(new CellCoordinate(firstRow, firstCol), key -> new HashSet<>());

for (int row = range.getFirstRow(); row <= range.getLastRow(); row++) {
for (int col = firstCol; col <= range.getLastColumn(); col++) {
// Skip anchor cell
if (row == firstRow && col == firstCol) {
continue;
}
cells.add(new CellCoordinate(row, col));
}
}
}

return result;
}

private int calculateRowSpan(List<AnalysisCell> analysisCellList) {
if (CollectionUtils.isEmpty(analysisCellList)) {
return 0;
}
IntSummaryStatistics stats =
analysisCellList.stream().mapToInt(AnalysisCell::getRowIndex).summaryStatistics();
return stats.getMax() - stats.getMin() + 1;
}

private void shiftRows(int size, int rowSpan, List<AnalysisCell> analysisCellList) {
if (CollectionUtils.isEmpty(analysisCellList)) {
return;
}
Expand All @@ -174,9 +276,9 @@ private void shiftRows(int size, List<AnalysisCell> analysisCellList) {
return;
}
Sheet sheet = writeContext.writeSheetHolder().getCachedSheet();
int number = size;
int number = size * rowSpan;
if (collectionLastIndexMap == null) {
number--;
number -= rowSpan;
}
if (number <= 0) {
return;
Expand Down Expand Up @@ -205,7 +307,11 @@ private void increaseRowIndex(
}

private void doFill(
List<AnalysisCell> analysisCellList, Object oneRowData, FillConfig fillConfig, Integer relativeRowIndex) {
List<AnalysisCell> analysisCellList,
Object oneRowData,
FillConfig fillConfig,
Integer relativeRowIndex,
Integer rowSpan) {
if (CollectionUtils.isEmpty(analysisCellList) || oneRowData == null) {
return;
}
Expand Down Expand Up @@ -247,7 +353,7 @@ private void doFill(
writeContext.currentWriteHolder());
cellWriteHandlerContext.setExcelContentProperty(excelContentProperty);

createCell(analysisCell, fillConfig, cellWriteHandlerContext, rowWriteHandlerContext);
createCell(analysisCell, fillConfig, cellWriteHandlerContext, rowWriteHandlerContext, rowSpan);
cellWriteHandlerContext.setOriginalValue(value);
cellWriteHandlerContext.setOriginalFieldClass(FieldUtils.getFieldClass(dataMap, variable, value));

Expand All @@ -268,7 +374,7 @@ private void doFill(
cellWriteHandlerContext.setExcelContentProperty(ExcelContentProperty.EMPTY);
cellWriteHandlerContext.setIgnoreFillStyle(Boolean.TRUE);

createCell(analysisCell, fillConfig, cellWriteHandlerContext, rowWriteHandlerContext);
createCell(analysisCell, fillConfig, cellWriteHandlerContext, rowWriteHandlerContext, rowSpan);
Cell cell = cellWriteHandlerContext.getCell();

for (String variable : analysisCell.getVariableList()) {
Expand Down Expand Up @@ -327,6 +433,9 @@ private void doFill(
WriteHandlerUtils.afterCellDispose(cellWriteHandlerContext);
}

// Handle multi-rows merge strategies
addMergedRegionIfNecessary(analysisCellList, fillConfig);

// In the case of the fill line may be called many times
if (rowWriteHandlerContext.getRow() != null) {
WriteHandlerUtils.afterRowDispose(rowWriteHandlerContext);
Expand All @@ -348,7 +457,8 @@ private void createCell(
AnalysisCell analysisCell,
FillConfig fillConfig,
CellWriteHandlerContext cellWriteHandlerContext,
RowWriteHandlerContext rowWriteHandlerContext) {
RowWriteHandlerContext rowWriteHandlerContext,
Integer rowSpan) {
Sheet cachedSheet = writeContext.writeSheetHolder().getCachedSheet();
if (WriteTemplateAnalysisCellTypeEnum.COMMON.equals(analysisCell.getCellType())) {
Row row = cachedSheet.getRow(analysisCell.getRowIndex());
Expand All @@ -375,7 +485,8 @@ private void createCell(
collectionLastIndexMap.put(analysisCell, lastRowIndex);
isOriginalCell = true;
} else {
collectionLastIndexMap.put(analysisCell, ++lastRowIndex);
lastRowIndex += rowSpan;
collectionLastIndexMap.put(analysisCell, lastRowIndex);
}
lastColumnIndex = analysisCell.getColumnIndex();
break;
Expand Down Expand Up @@ -673,4 +784,34 @@ public static class UniqueDataFlagKey {
private String sheetName;
private String wrapperName;
}

@Getter
@AllArgsConstructor
@EqualsAndHashCode
public static class CellCoordinate {
private final Integer rownum;
private final Integer column;

public Cell getOrCreateCell(Sheet sheet, Sheet cachedSheet) {
Row row = sheet.getRow(rownum);
if (null == row) {
// The last row of the middle disk inside empty rows, resulting in cachedSheet can not get inside.
// Will throw Attempting to write a row[" + rownum + "] " + "in the range [0," + this._sh
// .getLastRowNum() + "] that is already written to disk.
row = cachedSheet.getRow(rownum);
if (null == row) {
try {
row = sheet.createRow(rownum);
} catch (IllegalArgumentException ignore) {
row = cachedSheet.createRow(rownum);
}
}
}
Cell cell = row.getCell(column);
if (null == cell) {
return row.createCell(column);
}
return cell;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fesod.sheet.enums.FillMergeStrategy;
import org.apache.fesod.sheet.enums.WriteDirectionEnum;

/**
Expand Down Expand Up @@ -55,6 +56,20 @@ public class FillConfig {
*/
private Boolean autoStyle;

/**
* Strategy for handle merged regions during loop filling.
* <p>
* This strategy applies <b>ONLY</b> when using {@link WriteDirectionEnum#VERTICAL} fill direction with
* collection-based data.
* </p>
* <p>
* If used with {@link WriteDirectionEnum#HORIZONTAL}, these strategies (except {@link FillMergeStrategy#NONE})
* will throw an exception.
* </p>
* Defaults {@link FillMergeStrategy#NONE}.
*/
private FillMergeStrategy mergeStrategy;

private boolean hasInit;

public void init() {
Expand All @@ -70,6 +85,18 @@ public void init() {
if (autoStyle == null) {
autoStyle = Boolean.TRUE;
}
if (mergeStrategy == null) {
mergeStrategy = FillMergeStrategy.NONE;
}

validateConfigConstraint();
hasInit = true;
}

private void validateConfigConstraint() {
if (direction == WriteDirectionEnum.HORIZONTAL && mergeStrategy != FillMergeStrategy.NONE) {
throw new IllegalArgumentException("Conflict detected: Multi-row merge strategy (" + mergeStrategy + ") "
+ "is NOT supported when fill direction is HORIZONTAL.");
}
}
}
Loading
Loading