diff --git a/pom.xml b/pom.xml index 2a3b93ef7e0..5012fd376f4 100644 --- a/pom.xml +++ b/pom.xml @@ -549,6 +549,8 @@ **/com/google/gwt/**/*.* + + **/org/apache/poi/**/*.* diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/Cell.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/Cell.java index 4b998b0bbb7..88fa7a60681 100755 --- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/Cell.java +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/Cell.java @@ -38,6 +38,7 @@ public class Cell { private String value; private String cellStyle = "cs0"; + private String textColor; private boolean needsMeasure; private SheetWidget sheetWidget; private boolean overflowDirty = true; @@ -63,6 +64,7 @@ public Cell(SheetWidget sheetWidget, int col, int row, CellData cellData) { needsMeasure = cellData.needsMeasure; value = cellData.value; cellStyle = cellData.cellStyle; + textColor = cellData.textColor; } updateCellValues(); updateInnerText(); @@ -72,12 +74,17 @@ public DivElement getElement() { return element; } + public String getTextColor() { + return textColor; + } + public void update(int col, int row, CellData cellData) { this.col = col; this.row = row; - cellStyle = cellData == null ? "cs0" : cellData.cellStyle; - value = cellData == null ? null : cellData.value; - needsMeasure = cellData == null ? false : cellData.needsMeasure; + this.cellStyle = cellData == null ? "cs0" : cellData.cellStyle; + this.value = cellData == null ? null : cellData.value; + this.needsMeasure = cellData == null ? false : cellData.needsMeasure; + this.textColor = cellData == null ? null : cellData.textColor; updateInnerText(); updateCellValues(); @@ -87,6 +94,7 @@ public void update(int col, int row, CellData cellData) { private void updateInnerText() { element.getStyle().setOverflow(Overflow.HIDDEN); + element.getStyle().clearColor(); if (value == null || value.isEmpty()) { element.setInnerText(""); element.getStyle().clearZIndex(); @@ -100,6 +108,10 @@ private void updateInnerText() { } } + if (textColor != null) { + element.getStyle().setColor(textColor); + } + appendOverlayElements(); } @@ -221,12 +233,14 @@ public String getValue() { return value; } - public void setValue(String value, String cellStyle, boolean needsMeasure) { + public void setValue(String value, String cellStyle, String textColor, + boolean needsMeasure) { if (!this.cellStyle.equals(cellStyle)) { this.cellStyle = cellStyle; updateClassName(); } this.needsMeasure = needsMeasure; + this.textColor = textColor; setValue(value); } diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/CellData.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/CellData.java index 424f702fdbd..6ccd5c9066d 100644 --- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/CellData.java +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/CellData.java @@ -22,6 +22,7 @@ public class CellData implements Serializable { public String formulaValue; public String originalValue; public String cellStyle = "cs0"; + public String textColor; public boolean locked = false; public boolean needsMeasure; public boolean isPercentage; @@ -56,7 +57,7 @@ public boolean equals(Object obj) { @Override public String toString() { return new StringBuilder().append("r").append(row).append("c") - .append(col).append(cellStyle).append("|").append(value) - .toString(); + .append(col).append(cellStyle).append("tc").append(textColor) + .append("|").append(value).toString(); } } diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/SheetWidget.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/SheetWidget.java index b479f378e19..f3f1d97659b 100644 --- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/SheetWidget.java +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/SheetWidget.java @@ -3139,7 +3139,14 @@ else if (vScrollDiff < 0) { final ArrayList tempCols = new ArrayList(); for (Iterator cells = row.iterator(); cells.hasNext();) { Cell cell = cells.next(); + if (cell == null) { + // Cell can apparently be null here; scrolling will fail + // unless this is checked for. + continue; + } + int cIndex = cell.getCol(); + // scroll right if (hScrollDiff > 0) { // move cells from left to right @@ -3562,11 +3569,13 @@ public void addMergedRegion(MergedRegion region) { MergedCell mergedCell = new MergedCell(this, region.col1, region.row1); String cellStyle = "cs0"; Cell cell = getCell(region.col1, region.row1); + String textColor = null; if (cell != null) { cellStyle = cell.getCellStyle(); + textColor = cell.getTextColor(); } mergedCell.setValue(getCellValue(region.col1, region.row1), cellStyle, - false); + textColor, false); DivElement element = mergedCell.getElement(); element.addClassName(MERGED_CELL_CLASSNAME); updateMergedRegionRegionSize(region, mergedCell); @@ -3724,7 +3733,7 @@ public void removeMergedRegion(MergedRegion region, int ruleIndex) { Cell originalCell = getCell(region.col1, region.row1); if (originalCell != null) { originalCell.setValue(mCell.getValue(), mCell.getCellStyle(), - false); + originalCell.getTextColor(), false); } mergedCells.remove(region.id).getElement().removeFromParent(); overflownMergedCells.remove(region); @@ -3852,10 +3861,10 @@ private Cell getMergedCell(String key) { } private boolean setMergedCellValue(String key, String value, - String cellStyle, boolean needsMeasure) { + String cellStyle, String textColor, boolean needsMeasure) { Cell cell = getMergedCell(key); if (cell != null) { - cell.setValue(value, cellStyle, needsMeasure); + cell.setValue(value, cellStyle, textColor, needsMeasure); return true; } return false; @@ -4214,12 +4223,12 @@ public void updateTopLeftCellValues(List cellData2) { Iterator i = cellData2.iterator(); while (i.hasNext()) { CellData cd = i.next(); - topLeftCells - .get((cd.row - 1) * horizontalSplitPosition + cd.col - - 1) - .setValue(cd.value, cd.cellStyle, cd.needsMeasure); + topLeftCells.get( + (cd.row - 1) * horizontalSplitPosition + cd.col - 1) + .setValue(cd.value, cd.cellStyle, cd.textColor, + cd.needsMeasure); String key = toKey(cd.col, cd.row); - setMergedCellValue(key, cd.value, cd.cellStyle, + setMergedCellValue(key, cd.value, cd.cellStyle, cd.textColor, cd.needsMeasure); if (cd.value == null) { cachedCellData.remove(key); @@ -4266,10 +4275,11 @@ private void updateCellData(int r1, int r2, int c1, int c2, } } row.get(cd.col - c1).setValue(cd.value, cd.cellStyle, - cd.needsMeasure); + cd.textColor, cd.needsMeasure); } String key = toKey(cd.col, cd.row); - setMergedCellValue(key, cd.value, cd.cellStyle, cd.needsMeasure); + setMergedCellValue(key, cd.value, cd.cellStyle, cd.textColor, + cd.needsMeasure); if (cd.value == null) { cachedCellData.remove(key); } else { @@ -4291,7 +4301,7 @@ public void cellValuesUpdated(ArrayList updatedCellData) { } else { cachedCellData.put(key, cd); } - if (!setMergedCellValue(key, cd.value, cd.cellStyle, + if (!setMergedCellValue(key, cd.value, cd.cellStyle, cd.textColor, cd.needsMeasure)) { Cell cell = null; if (isCellRenderedInScrollPane(cd.col, cd.row)) { @@ -4302,7 +4312,8 @@ public void cellValuesUpdated(ArrayList updatedCellData) { } if (cell != null) { - cell.setValue(cd.value, cd.cellStyle, cd.needsMeasure); + cell.setValue(cd.value, cd.cellStyle, cd.textColor, + cd.needsMeasure); cell.markAsOverflowDirty(); } int j = verticalSplitPosition > 0 ? 0 : firstColumnIndex; diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/SpreadsheetWidget.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/SpreadsheetWidget.java index 7ea319dee76..997eae87b8b 100644 --- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/SpreadsheetWidget.java +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-client/src/main/java/com/vaadin/addon/spreadsheet/client/SpreadsheetWidget.java @@ -508,7 +508,7 @@ public void execute() { // initial display only used single column width, // re-calculate with merged width cell.setValue(cell.getValue(), cell.getCellStyle(), - false); + cell.getTextColor(), false); } i++; } diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-integration-tests/src/main/resources/test_sheets/custom_formatting_rainbow.xlsx b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-integration-tests/src/main/resources/test_sheets/custom_formatting_rainbow.xlsx new file mode 100644 index 00000000000..d63277c8007 Binary files /dev/null and b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-integration-tests/src/main/resources/test_sheets/custom_formatting_rainbow.xlsx differ diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-integration-tests/src/test/java/com/vaadin/flow/component/spreadsheet/test/CustomFormattingIT.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-integration-tests/src/test/java/com/vaadin/flow/component/spreadsheet/test/CustomFormattingIT.java new file mode 100644 index 00000000000..b9487d4cc8e --- /dev/null +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow-integration-tests/src/test/java/com/vaadin/flow/component/spreadsheet/test/CustomFormattingIT.java @@ -0,0 +1,199 @@ +/** + * Copyright 2000-2025 Vaadin Ltd. + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * See {@literal } for the full + * license. + */ +package com.vaadin.flow.component.spreadsheet.test; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import com.vaadin.flow.component.spreadsheet.testbench.SheetCellElement; +import com.vaadin.flow.testutil.TestPath; + +@TestPath("vaadin-spreadsheet") +public class CustomFormattingIT extends AbstractSpreadsheetIT { + + // These values are specific to the test sheet + private static final int ROW_OFFSET = 6; + private static final int COL_OFFSET = 1; + private static final int ROW_COUNT = 8; + private static final int COL_COUNT = 8; + + // The 8 basic named colors, in order in the document, as CSS strings + private static final String[] NAMED_COLOR_CSS = { // new line here + "rgba(0, 0, 0, 1)", // black + "rgba(0, 0, 255, 1)", // blue + "rgba(0, 255, 255, 1)", // cyan + "rgba(0, 255, 0, 1)", // green + "rgba(255, 0, 0, 1)", // red + "rgba(255, 255, 255, 1)", // white + "rgba(255, 255, 0, 1)", // yellow + "rgba(255, 0, 255, 1)" // magenta + }; + + // The remaining 7 rows of indexed colors, as CSS strings + private static final String[] INDEXED_COLOR_CSS = new String[56]; + static { + // Color values in hex, taken from the modified CellFormatPart.java + // in vaadin-spreadsheet-flow. These are the 56 indexed Excel colors + final int[] rgb_hex = { // new line here + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, + 0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, + 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, + 0xFFFFCC, 0xCCFFFF, 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, + 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, + 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, 0xCCFFCC, 0xFFFF99, + 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, + 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, + 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, + 0x333399, 0x333333 }; + + // Convert table to CSS strings + for (int i = 0; i < 56; ++i) { + INDEXED_COLOR_CSS[i] = "rgba(" + // results returnd as rgba + ((rgb_hex[i] >>> 16) & 0xff) + ", " + // red component + ((rgb_hex[i] >>> 8) & 0xff) + ", " + // green component + (rgb_hex[i] & 0xff) + ", 1)"; // blue component and opaque + } + } + + @Before + public void init() { + open(); + loadFile("custom_formatting_rainbow.xlsx"); + } + + private void validateAllColors() { + validateNamedColors(); + validateIndexedColors(); + } + + private void validateNamedColors() { + // Verify all basic colors + for (int col = 0; col < COL_COUNT; ++col) { + SheetCellElement cell = getSpreadsheet().getCellAt(ROW_OFFSET, + col + COL_OFFSET); + Assert.assertEquals(NAMED_COLOR_CSS[col], + cell.getCssValue("color")); + } + } + + private void validateIndexedColors() { + // Verify all indexed colors (from second row of table onwards) + for (int row = 0; row < (ROW_COUNT - 1); ++row) { + for (int col = 0; col < COL_COUNT; ++col) { + SheetCellElement cell = getSpreadsheet() + .getCellAt(row + ROW_OFFSET + 1, col + COL_OFFSET); + Assert.assertEquals(INDEXED_COLOR_CSS[row * COL_COUNT + col], + cell.getCssValue("color")); + } + } + } + + @Test + public void customFormatting_verifyBasicColorsPresent() { + validateNamedColors(); + } + + @Test + public void customFormatting_verifyIndexedColors() { + validateIndexedColors(); + } + + @Test + public void customFormatting_verifyAllFormulasValid() { + // Test initial conditions, make sure no cell is marked as invalid + for (int row = 0; row < ROW_COUNT; ++row) { + for (int col = 0; col < ROW_COUNT; ++col) { + SheetCellElement cell = getSpreadsheet() + .getCellAt(row + ROW_OFFSET, col + COL_OFFSET); + Assert.assertFalse(cell.hasInvalidFormulaIndicator()); + } + } + } + + @Test + public void customFormatting_verifyAllFormulasValidAfterValueChange_Positive() { + // Control cell other cells copy their value from + SheetCellElement testValueCell = getSpreadsheet().getCellAt(4, 2); + + // Test positive value + testValueCell.setValue("98"); + for (int row = 0; row < ROW_COUNT; ++row) { + for (int col = 0; col < COL_COUNT; ++col) { + SheetCellElement cell = getSpreadsheet() + .getCellAt(row + ROW_OFFSET, col + COL_OFFSET); + Assert.assertFalse(cell.hasInvalidFormulaIndicator()); + Assert.assertEquals(Double.parseDouble(cell.getValue()), + Double.parseDouble(testValueCell.getValue()), 0d); + } + } + + validateAllColors(); + } + + @Test + public void customFormatting_verifyAllFormulasValidAfterValueChange_Negative() { + // Control cell other cells copy their value from + SheetCellElement testValueCell = getSpreadsheet().getCellAt(4, 2); + + // Test negative value + testValueCell.setValue("-75"); + for (int row = 0; row < ROW_COUNT; ++row) { + for (int col = 0; col < COL_COUNT; ++col) { + SheetCellElement cell = getSpreadsheet() + .getCellAt(row + ROW_OFFSET, col + COL_OFFSET); + Assert.assertFalse(cell.hasInvalidFormulaIndicator()); + Assert.assertEquals( + Double.parseDouble(cell.getValue().replace('(', ' ') + .replace(')', ' ')), + -Double.parseDouble(testValueCell.getValue()), 0d); + } + } + + validateAllColors(); + } + + @Test + public void customFormatting_verifyAllFormulasValidAfterValueChange_Zero() { + // Control cell other cells copy their value from + SheetCellElement testValueCell = getSpreadsheet().getCellAt(4, 2); + + // Test zero value + testValueCell.setValue("0"); + for (int row = 0; row < ROW_COUNT; ++row) { + for (int col = 0; col < COL_COUNT; ++col) { + SheetCellElement cell = getSpreadsheet() + .getCellAt(row + ROW_OFFSET, col + COL_OFFSET); + Assert.assertFalse(cell.hasInvalidFormulaIndicator()); + Assert.assertEquals("===", cell.getValue()); + } + } + + validateAllColors(); + } + + @Test + public void customFormatting_verifyAllFormulasValidAfterValueChange_String() { + // Control cell other cells copy their value from + SheetCellElement testValueCell = getSpreadsheet().getCellAt(4, 2); + + // Test string value + testValueCell.setValue("test"); + for (int row = 0; row < ROW_COUNT; ++row) { + for (int col = 0; col < COL_COUNT; ++col) { + SheetCellElement cell = getSpreadsheet() + .getCellAt(row + ROW_OFFSET, col + COL_OFFSET); + Assert.assertFalse(cell.hasInvalidFormulaIndicator()); + Assert.assertEquals("\"test\"", cell.getValue()); + } + } + + validateAllColors(); + } +} diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/CellValueManager.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/CellValueManager.java index 9d583fac466..47173bb6406 100644 --- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/CellValueManager.java +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/CellValueManager.java @@ -240,7 +240,14 @@ protected CellData createCellDataForCell(Cell cell) { cell.getColumnIndex() + 1, cell.getRowIndex() + 1); } + } + } + if (formatter instanceof CustomDataFormatter) { + String color = ((CustomDataFormatter) formatter) + .getCellTextColor(cell); + if (color != null) { + cellData.textColor = color; } } diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/CustomDataFormatter.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/CustomDataFormatter.java index 69a07986b0a..3b073da1134 100644 --- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/CustomDataFormatter.java +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/CustomDataFormatter.java @@ -8,11 +8,13 @@ */ package com.vaadin.flow.component.spreadsheet; +import java.awt.Color; import java.io.Serializable; import java.util.Locale; import java.util.regex.Pattern; import org.apache.poi.ss.format.CellFormat; +import org.apache.poi.ss.format.CellFormatResult; import org.apache.poi.ss.formula.ConditionalFormattingEvaluator; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellType; @@ -127,15 +129,54 @@ private String formatNumericValueUsingFormatPart(Cell cell, if (isOnlyLiteralFormat(format)) { // CellFormat can format literals correctly - return formatTextUsingCellFormat(cell, format); + return formatTextUsingCellFormat(cell, format).text; } else { // DataFormatter can format numbers correctly return super.formatCellValue(cell, evaluator, cfEvaluator); } } - private String formatTextUsingCellFormat(Cell cell, String format) { - return CellFormat.getInstance(locale, format).apply(cell).text; + private CellFormatResult formatTextUsingCellFormat(Cell cell, + String format) { + // TODO: replace this with a reference to CellFormat when moving back to + // mainline Apache POI. + return CellFormat.getInstance(locale, format).apply(cell); + } + + /** + * Get the applicable text color for the cell. This uses Apache POI's + * CellFormat logic, which parses and evaluates the cell's format string + * against the cell's current value. + * + * @param cell + * The cell to get the applicable custom formatting text color + * for. + * @return a CSS color value string, or null if no text color should be + * applied. + */ + public String getCellTextColor(Cell cell) { + try { + final String format = cell.getCellStyle().getDataFormatString(); + if (format == null || format.isEmpty() || isGeneralFormat(format)) { + return null; + } + + CellFormatResult result = formatTextUsingCellFormat(cell, format); + + if (result.textColor == null) { + return null; + } + + Color color = result.textColor; // AWT color value returned by POI + + // rgb(N,N,N) turned out to be the most reliably transmitted string + // in testing + String css = "rgb(" + color.getRed() + "," + color.getGreen() + "," + + color.getBlue() + ")"; + return css; + } catch (Exception e) { + return null; + } } private String getNumericFormat(double value, String[] formatParts) { @@ -181,6 +222,6 @@ private String formatStringCellValue(Cell cell, String formatString, return ""; } - return formatTextUsingCellFormat(cell, formatString); + return formatTextUsingCellFormat(cell, formatString).text; } } diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/Spreadsheet.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/Spreadsheet.java index 2060ee51477..741b86917ee 100644 --- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/Spreadsheet.java +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/Spreadsheet.java @@ -36,6 +36,7 @@ import org.apache.poi.hssf.usermodel.HSSFSheet; import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.format.CellFormatPart; import org.apache.poi.ss.formula.BaseFormulaEvaluator; import org.apache.poi.ss.formula.ConditionalFormattingEvaluator; import org.apache.poi.ss.usermodel.Cell; @@ -141,6 +142,11 @@ public class Spreadsheet extends Component "vaadin-spreadsheet-flow", version); } } + + // Force load our variant CellFormatPart.java + // TODO: this is a hack; remove this once color support is fixed in POI + CellFormatPart cf = new CellFormatPart("general"); + cf.applies(1000d); } @Override diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/client/CellData.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/client/CellData.java index 95bae9c7bca..05f67e0752b 100644 --- a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/client/CellData.java +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/com/vaadin/flow/component/spreadsheet/client/CellData.java @@ -22,6 +22,7 @@ public class CellData implements Serializable { public String formulaValue; public String originalValue; public String cellStyle = "cs0"; + public String textColor; public boolean locked = false; public boolean needsMeasure; public boolean isPercentage; @@ -56,7 +57,7 @@ public boolean equals(Object obj) { @Override public String toString() { return new StringBuilder().append("r").append(row).append("c") - .append(col).append(cellStyle).append("|").append(value) - .toString(); + .append(col).append(cellStyle).append("tc").append(textColor) + .append("|").append(value).toString(); } } diff --git a/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/org/apache/poi/ss/format/CellFormatPart.java b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/org/apache/poi/ss/format/CellFormatPart.java new file mode 100644 index 00000000000..730416e859c --- /dev/null +++ b/vaadin-spreadsheet-flow-parent/vaadin-spreadsheet-flow/src/main/java/org/apache/poi/ss/format/CellFormatPart.java @@ -0,0 +1,715 @@ +/* ==================================================================== + 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.poi.ss.format; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.poi.hssf.util.HSSFColor; +import org.apache.poi.util.CodepointsUtil; +import org.apache.poi.util.LocaleUtil; + +import javax.swing.*; + +import java.awt.*; +import java.util.*; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.apache.poi.ss.format.CellFormatter.quote; + +/* + * Note: This file has been overridden by Vaadin to allow proper formatting + * of all available colors. This override should be removed once the color + * formatting has been fixed upstream in Apache POI. + * + * This class has been heavily modified in order to provide full support + * for colors supported by Excel. + * + * As of February 2025, Apache POI's implementation of this class does not + * provide Excel-correct color values, does not support indexed color at all + * ánd is missing the definition for magenta completely. + * + * TODO: when upgrading Apache POI, check to see if this class has been + * updated with correct color support! + */ + +/** + * Objects of this class represent a single part of a cell format expression. + * Each cell can have up to four of these for positive, zero, negative, and text + * values. + *

+ * Each format part can contain a color, a condition, and will always contain a + * format specification. For example {@code "[Red][>=10]#"} has a color + * ({@code [Red]}), a condition ({@code >=10}) and a format specification + * ({@code #}). + *

+ * This class also contains patterns for matching the subparts of format + * specification. These are used internally, but are made public in case other + * code has use for them. + */ +@SuppressWarnings("RegExpRepeatedSpace") +public class CellFormatPart { + private static final Logger LOG = LogManager.getLogger(CellFormatPart.class); + + static final Map NAMED_COLORS; + static final List INDEXED_COLORS; + + private final Color color; + private final CellFormatCondition condition; + private final CellFormatter format; + private final CellFormatType type; + + /** Pattern for the color part of a cell format part. */ + public static final Pattern COLOR_PAT; + /** Pattern for the condition part of a cell format part. */ + public static final Pattern CONDITION_PAT; + /** Pattern for the format specification part of a cell format part. */ + public static final Pattern SPECIFICATION_PAT; + /** Pattern for the currency symbol part of a cell format part */ + public static final Pattern CURRENCY_PAT; + /** Pattern for an entire cell single part. */ + public static final Pattern FORMAT_PAT; + + /** Within {@link #FORMAT_PAT}, the group number for the matched color. */ + public static final int COLOR_GROUP; + + /** + * Within {@link #FORMAT_PAT}, the group number for the operator in the + * condition. + */ + public static final int CONDITION_OPERATOR_GROUP; + /** + * Within {@link #FORMAT_PAT}, the group number for the value in the + * condition. + */ + public static final int CONDITION_VALUE_GROUP; + /** + * Within {@link #FORMAT_PAT}, the group number for the format + * specification. + */ + public static final int SPECIFICATION_GROUP; + + static { + // Build indexed color list based on this table + // https://www.excelsupersite.com/what-are-the-56-colorindex-colors-in-excel/ + INDEXED_COLORS = List.of( + new Color(0x000000), // Color 1 / black + new Color(0xFFFFFF), // Color 2 / white + new Color(0xFF0000), // Color 3 / red + new Color(0x00FF00), // Color 4 / green + new Color(0x0000FF), // Color 5 / blue + new Color(0xFFFF00), // Color 6 / yellow + new Color(0xFF00FF), // Color 7 / magenta + new Color(0x00FFFF), // Color 8 / cyan + new Color(0x800000), // Color 9 + new Color(0x008000), // Color 10 + new Color(0x000080), // Color 11 + new Color(0x808000), // Color 12 + new Color(0x800080), // Color 13 + new Color(0x008080), // Color 14 + new Color(0xC0C0C0), // Color 15 + new Color(0x808080), // Color 16 + new Color(0x9999FF), // Color 17 + new Color(0x993366), // Color 18 + new Color(0xFFFFCC), // Color 19 + new Color(0xCCFFFF), // Color 20 + new Color(0x660066), // Color 21 + new Color(0xFF8080), // Color 22 + new Color(0x0066CC), // Color 23 + new Color(0xCCCCFF), // Color 24 + new Color(0x000080), // Color 25 + new Color(0xFF00FF), // Color 26 + new Color(0xFFFF00), // Color 27 + new Color(0x00FFFF), // Color 28 + new Color(0x800080), // Color 29 + new Color(0x800000), // Color 30 + new Color(0x008080), // Color 31 + new Color(0x0000FF), // Color 32 + new Color(0x00CCFF), // Color 33 + new Color(0xCCFFFF), // Color 34 + new Color(0xCCFFCC), // Color 35 + new Color(0xFFFF99), // Color 36 + new Color(0x99CCFF), // Color 37 + new Color(0xFF99CC), // Color 38 + new Color(0xCC99FF), // Color 39 + new Color(0xFFCC99), // Color 40 + new Color(0x3366FF), // Color 41 + new Color(0x33CCCC), // Color 42 + new Color(0x99CC00), // Color 43 + new Color(0xFFCC00), // Color 44 + new Color(0xFF9900), // Color 45 + new Color(0xFF6600), // Color 46 + new Color(0x666699), // Color 47 + new Color(0x969696), // Color 48 + new Color(0x003366), // Color 49 + new Color(0x339966), // Color 50 + new Color(0x003300), // Color 51 + new Color(0x333300), // Color 52 + new Color(0x993300), // Color 53 + new Color(0x993366), // Color 54 + new Color(0x333399), // Color 55 + new Color(0x333333) // Color 56 + ); + + // Build named color list based on HSSFColorPredefined, just as + // Apache POI does it originally. This gives us a wider range + // of acceptable colors, but also puts out outside the acceptable + // color range of Excel. + NAMED_COLORS = new TreeMap<>( + String.CASE_INSENSITIVE_ORDER); + + for (HSSFColor.HSSFColorPredefined color : HSSFColor.HSSFColorPredefined.values()) { + String name = color.name().toLowerCase(); + short[] rgb = color.getTriplet(); + Color c = new Color(rgb[0], rgb[1], rgb[2]); + NAMED_COLORS.put(name, c); + if (name.indexOf("_percent") > 0) { + NAMED_COLORS.put(name.replace("_percent", "%") + .replaceAll("\\_", " "), c); + } + if (name.indexOf('_') > 0) { + NAMED_COLORS.put(name.replaceAll("\\_", " "), c); + } + } + + // Replace the standard colors with standard values + // to support Excel like functionality + NAMED_COLORS.put("black", INDEXED_COLORS.get(0)); + NAMED_COLORS.put("white", INDEXED_COLORS.get(1)); + NAMED_COLORS.put("red", INDEXED_COLORS.get(2)); + NAMED_COLORS.put("green", INDEXED_COLORS.get(3)); + NAMED_COLORS.put("blue", INDEXED_COLORS.get(4)); + NAMED_COLORS.put("yellow", INDEXED_COLORS.get(5)); + NAMED_COLORS.put("magenta", INDEXED_COLORS.get(6)); + NAMED_COLORS.put("cyan", INDEXED_COLORS.get(7)); + + // A condition specification + String condition = "([<>=]=?|!=|<>) # The operator\n" + + " \\s*(-?([0-9]+(?:\\.[0-9]*)?)|(\\.[0-9]*))\\s* # The constant to test against\n"; + + // A currency symbol / string, in a specific locale + String currency = "(\\[\\$.{0,3}(-[0-9a-f]{3,4})?])"; + + // A number specification + // Note: careful that in something like ##, that the trailing comma is not caught up in the integer part + + // A part of a specification + //noinspection RegExpRedundantEscape + String part = "\\\\. # Quoted single character\n" + + "|\"([^\\\\\"]|\\\\.)*\" # Quoted string of characters (handles escaped quotes like \\\") \n" + + "|"+currency+" # Currency symbol in a given locale\n" + + "|_. # Space as wide as a given character\n" + + "|\\*. # Repeating fill character\n" + + "|@ # Text: cell text\n" + + "|([0?\\#][0?\\#,]*) # Number: digit + other digits and commas\n" + + "|e[-+] # Number: Scientific: Exponent\n" + + "|m{1,5} # Date: month or minute spec\n" + + "|d{1,4} # Date: day/date spec\n" + + "|y{2,4} # Date: year spec\n" + + "|h{1,2} # Date: hour spec\n" + + "|s{1,2} # Date: second spec\n" + + "|am?/pm? # Date: am/pm spec\n" + + "|\\[h{1,2}] # Elapsed time: hour spec\n" + + "|\\[m{1,2}] # Elapsed time: minute spec\n" + + "|\\[s{1,2}] # Elapsed time: second spec\n" + + "|[^;] # A character\n" + ""; + + // Build the color code matching expression. + // We should match any named color in the set as well as a string in the form + // of "Color 8" or "Color 15". + String color = "\\[("; + for (String key : NAMED_COLORS.keySet()) { + // Escape special characters in the color name + color += key.replaceAll("([^a-zA-Z0-9])", "\\\\$1") + "|"; + } + // Match the indexed color table + color += "color\\ [0-9]+)\\]"; + + String format = "(?:" + color + ")? # Text color\n" + + "(?:\\[" + condition + "])? # Condition\n" + + // see https://msdn.microsoft.com/en-ca/goglobal/bb964664.aspx and https://bz.apache.org/ooo/show_bug.cgi?id=70003 + // we ignore these for now though + "(?:\\[\\$-[0-9a-fA-F]+])? # Optional locale id, ignored currently\n" + + "((?:" + part + ")+) # Format spec\n"; + + int flags = Pattern.COMMENTS | Pattern.CASE_INSENSITIVE; + COLOR_PAT = Pattern.compile(color, flags); + CONDITION_PAT = Pattern.compile(condition, flags); + SPECIFICATION_PAT = Pattern.compile(part, flags); + CURRENCY_PAT = Pattern.compile(currency, flags); + FORMAT_PAT = Pattern.compile(format, flags); + + // Calculate the group numbers of important groups. (They shift around + // when the pattern is changed; this way we figure out the numbers by + // experimentation.) + + COLOR_GROUP = findGroup(FORMAT_PAT, "[Blue]@", "Blue"); + CONDITION_OPERATOR_GROUP = findGroup(FORMAT_PAT, "[>=1]@", ">="); + CONDITION_VALUE_GROUP = findGroup(FORMAT_PAT, "[>=1]@", "1"); + SPECIFICATION_GROUP = findGroup(FORMAT_PAT, "[Blue][>1]\\a ?", "\\a ?"); + + // Once patterns have been compiled, add indexed colors to + // NAMED_COLORS so they can be easily picked up by an unmodified + // getColor() implementation + for (int i = 0; i < INDEXED_COLORS.size(); ++i) { + NAMED_COLORS.put("color " + (i + 1), INDEXED_COLORS.get(i)); + } + // NOTE: the INDEXED_COLORS list is retained for future use, even + // though it is not currently utilized outside the static + // initialization logic. + } + + interface PartHandler { + String handlePart(Matcher m, String part, CellFormatType type, + StringBuffer desc); + } + + /** + * Create an object to represent a format part. + * + * @param desc The string to parse. + */ + public CellFormatPart(String desc) { + this(LocaleUtil.getUserLocale(), desc); + } + + /** + * Create an object to represent a format part. + * + * @param locale The locale to use. + * @param desc The string to parse. + */ + public CellFormatPart(Locale locale, String desc) { + Matcher m = FORMAT_PAT.matcher(desc); + if (!m.matches()) { + throw new IllegalArgumentException("Unrecognized format: " + quote( + desc)); + } + color = getColor(m); + condition = getCondition(m); + type = getCellFormatType(m); + format = getFormatter(locale, m); + } + + /** + * Returns {@code true} if this format part applies to the given value. If + * the value is a number and this is part has a condition, returns + * {@code true} only if the number passes the condition. Otherwise, this + * always return {@code true}. + * + * @param valueObject The value to evaluate. + * + * @return {@code true} if this format part applies to the given value. + */ + public boolean applies(Object valueObject) { + if (condition == null || !(valueObject instanceof Number)) { + if (valueObject == null) + throw new NullPointerException("valueObject"); + return true; + } else { + Number num = (Number) valueObject; + return condition.pass(num.doubleValue()); + } + } + + /** + * Returns the number of the first group that is the same as the marker + * string. Starts from group 1. + * + * @param pat The pattern to use. + * @param str The string to match against the pattern. + * @param marker The marker value to find the group of. + * + * @return The matching group number. + * + * @throws IllegalArgumentException No group matches the marker. + */ + private static int findGroup(Pattern pat, String str, String marker) { + Matcher m = pat.matcher(str); + if (!m.find()) + throw new IllegalArgumentException( + "Pattern \"" + pat.pattern() + "\" doesn't match \"" + str + + "\""); + for (int i = 1; i <= m.groupCount(); i++) { + String grp = m.group(i); + if (grp != null && grp.equals(marker)) + return i; + } + throw new IllegalArgumentException( + "\"" + marker + "\" not found in \"" + pat.pattern() + "\""); + } + + /** + * Returns the color specification from the matcher, or {@code null} if + * there is none. + * + * @param m The matcher for the format part. + * + * @return The color specification or {@code null}. + */ + private static Color getColor(Matcher m) { + String cdesc = m.group(COLOR_GROUP); + + if (cdesc == null || cdesc.length() == 0) { + return null; + } + + Color c = NAMED_COLORS.get(cdesc); + if (c == null) { + LOG.error("Unknown color: {}", quote(cdesc)); + } + return c; + } + + /** + * Returns the condition specification from the matcher, or {@code null} if + * there is none. + * + * @param m The matcher for the format part. + * + * @return The condition specification or {@code null}. + */ + private CellFormatCondition getCondition(Matcher m) { + String mdesc = m.group(CONDITION_OPERATOR_GROUP); + if (mdesc == null || mdesc.length() == 0) + return null; + return CellFormatCondition.getInstance(m.group( + CONDITION_OPERATOR_GROUP), m.group(CONDITION_VALUE_GROUP)); + } + + /** + * Returns the CellFormatType object implied by the format specification for + * the format part. + * + * @param matcher The matcher for the format part. + * + * @return The CellFormatType. + */ + private CellFormatType getCellFormatType(Matcher matcher) { + String fdesc = matcher.group(SPECIFICATION_GROUP); + return formatType(fdesc); + } + + /** + * Returns the formatter object implied by the format specification for the + * format part. + * + * @param matcher The matcher for the format part. + * + * @return The formatter. + */ + private CellFormatter getFormatter(Locale locale, Matcher matcher) { + String fdesc = matcher.group(SPECIFICATION_GROUP); + + // For now, we don't support localised currencies, so simplify if there + Matcher currencyM = CURRENCY_PAT.matcher(fdesc); + if (currencyM.find()) { + String currencyPart = currencyM.group(1); + String currencyRepl; + if (currencyPart.startsWith("[$-")) { + // Default $ in a different locale + currencyRepl = "$"; + } else if (!currencyPart.contains("-")) { + // Accounting formats such as USD [$USD] + currencyRepl = currencyPart.substring(2, currencyPart.indexOf("]")); + } else { + currencyRepl = currencyPart.substring(2, currencyPart.lastIndexOf('-')); + } + fdesc = fdesc.replace(currencyPart, currencyRepl); + } + + // Build a formatter for this simplified string + return type.formatter(locale, fdesc); + } + + /** + * Returns the type of format. + * + * @param fdesc The format specification + * + * @return The type of format. + */ + private CellFormatType formatType(String fdesc) { + fdesc = fdesc.trim(); + if (fdesc.isEmpty() || fdesc.equalsIgnoreCase("General")) + return CellFormatType.GENERAL; + + Matcher m = SPECIFICATION_PAT.matcher(fdesc); + boolean couldBeDate = false; + boolean seenZero = false; + while (m.find()) { + String repl = m.group(0); + Iterator codePoints = CodepointsUtil.iteratorFor(repl); + if (codePoints.hasNext()) { + String c1 = codePoints.next(); + + switch (c1) { + case "@": + return CellFormatType.TEXT; + case "d": + case "D": + case "y": + case "Y": + return CellFormatType.DATE; + case "h": + case "H": + case "m": + case "M": + case "s": + case "S": + // These can be part of date, or elapsed + couldBeDate = true; + break; + case "0": + // This can be part of date, elapsed, or number + seenZero = true; + break; + case "[": + String c2 = null; + if (codePoints.hasNext()) + c2 = codePoints.next().toLowerCase(Locale.ROOT); + if ("h".equals(c2) || "m".equals(c2) || "s".equals(c2)) { + return CellFormatType.ELAPSED; + } + if ("$".equals(c2)) { + // Localised currency + return CellFormatType.NUMBER; + } + // Something else inside [] which isn't supported! + throw new IllegalArgumentException("Unsupported [] format block '" + + repl + "' in '" + fdesc + "' with c2: " + c2); + case "#": + case "?": + return CellFormatType.NUMBER; + } + } + } + + // Nothing definitive was found, so we figure out it deductively + if (couldBeDate) + return CellFormatType.DATE; + if (seenZero) + return CellFormatType.NUMBER; + return CellFormatType.TEXT; + } + + /** + * Returns a version of the original string that has any special characters + * quoted (or escaped) as appropriate for the cell format type. The format + * type object is queried to see what is special. + * + * @param repl The original string. + * @param type The format type representation object. + * + * @return A version of the string with any special characters replaced. + * + * @see CellFormatType#isSpecial(char) + */ + static String quoteSpecial(String repl, CellFormatType type) { + StringBuilder sb = new StringBuilder(); + PrimitiveIterator.OfInt codePoints = CodepointsUtil.primitiveIterator(repl); + + int codepoint; + while (codePoints.hasNext()) { + codepoint = codePoints.nextInt(); + if (codepoint == '\'' && type.isSpecial('\'')) { + sb.append('\u0000'); + continue; + } + + char[] chars = Character.toChars(codepoint); + boolean special = type.isSpecial(chars[0]); + if (special) + sb.append('\''); + sb.append(chars); + if (special) + sb.append('\''); + } + return sb.toString(); + } + + /** + * Apply this format part to the given value. This returns a {@link + * CellFormatResult} object with the results. + * + * @param value The value to apply this format part to. + * + * @return A {@link CellFormatResult} object containing the results of + * applying the format to the value. + */ + public CellFormatResult apply(Object value) { + boolean applies = applies(value); + String text; + Color textColor; + if (applies) { + text = format.format(value); + textColor = color; + } else { + text = format.simpleFormat(value); + textColor = null; + } + return new CellFormatResult(applies, text, textColor); + } + + /** + * Apply this format part to the given value, applying the result to the + * given label. + * + * @param label The label + * @param value The value to apply this format part to. + * + * @return {@code true} if the + */ + public CellFormatResult apply(JLabel label, Object value) { + CellFormatResult result = apply(value); + label.setText(result.text); + if (result.textColor != null) { + label.setForeground(result.textColor); + } + return result; + } + + /** + * Returns the CellFormatType object implied by the format specification for + * the format part. + * + * @return The CellFormatType. + */ + CellFormatType getCellFormatType() { + return type; + } + + /** + * Returns {@code true} if this format part has a condition. + * + * @return {@code true} if this format part has a condition. + */ + boolean hasCondition() { + return condition != null; + } + + public static StringBuffer parseFormat(String fdesc, CellFormatType type, + PartHandler partHandler) { + + // Quoting is very awkward. In the Java classes, quoting is done + // between ' chars, with '' meaning a single ' char. The problem is that + // in Excel, it is legal to have two adjacent escaped strings. For + // example, consider the Excel format "\a\b#". The naive (and easy) + // translation into Java DecimalFormat is "'a''b'#". For the number 17, + // in Excel you would get "ab17", but in Java it would be "a'b17" -- the + // '' is in the middle of the quoted string in Java. So the trick we + // use is this: When we encounter a ' char in the Excel format, we + // output a \u0000 char into the string. Now we know that any '' in the + // output is the result of two adjacent escaped strings. So after the + // main loop, we have to do two passes: One to eliminate any '' + // sequences, to make "'a''b'" become "'ab'", and another to replace any + // \u0000 with '' to mean a quote char. Oy. + // + // For formats that don't use "'" we don't do any of this + Matcher m = SPECIFICATION_PAT.matcher(fdesc); + StringBuffer fmt = new StringBuffer(); + while (m.find()) { + String part = group(m, 0); + if (part.length() > 0) { + String repl = partHandler.handlePart(m, part, type, fmt); + if (repl == null) { + switch (part.charAt(0)) { + case '\"': + repl = quoteSpecial(part.substring(1, + part.length() - 1), type); + break; + case '\\': + repl = quoteSpecial(part.substring(1), type); + break; + case '_': + repl = " "; + break; + case '*': //!! We don't do this for real, we just put in 3 of them + repl = expandChar(part); + break; + default: + repl = part; + break; + } + } + m.appendReplacement(fmt, Matcher.quoteReplacement(repl)); + } + } + m.appendTail(fmt); + + if (type.isSpecial('\'')) { + // Now the next pass for quoted characters: Remove '' chars, making "'a''b'" into "'ab'" + int pos = 0; + while ((pos = fmt.indexOf("''", pos)) >= 0) { + fmt.delete(pos, pos + 2); + if (partHandler instanceof CellDateFormatter.DatePartHandler) { + CellDateFormatter.DatePartHandler datePartHandler = (CellDateFormatter.DatePartHandler) partHandler; + datePartHandler.updatePositions(pos, -2); + } + } + + // Now the final pass for quoted chars: Replace any \u0000 with '' + pos = 0; + while ((pos = fmt.indexOf("\u0000", pos)) >= 0) { + fmt.replace(pos, pos + 1, "''"); + if (partHandler instanceof CellDateFormatter.DatePartHandler) { + CellDateFormatter.DatePartHandler datePartHandler = (CellDateFormatter.DatePartHandler) partHandler; + datePartHandler.updatePositions(pos, 1); + } + } + } + + return fmt; + } + + /** + * Expands a character. This is only partly done, because we don't have the + * correct info. In Excel, this would be expanded to fill the rest of the + * cell, but we don't know, in general, what the "rest of the cell" is. + * + * @param part The character to be repeated is the second character in this + * string. + * + * @return The character repeated three times. + */ + static String expandChar(String part) { + List codePoints = new ArrayList<>(); + CodepointsUtil.iteratorFor(part).forEachRemaining(codePoints::add); + if (codePoints.size() < 2) throw new IllegalArgumentException("Expected part string to have at least 2 chars"); + String ch = codePoints.get(1); + return ch + ch + ch; + } + + /** + * Returns the string from the group, or {@code ""} if the group is + * {@code null}. + * + * @param m The matcher. + * @param g The group number. + * + * @return The group or {@code ""}. + */ + public static String group(Matcher m, int g) { + String str = m.group(g); + return (str == null ? "" : str); + } + + public String toString() { + return format.format; + } +}