Last active
June 3, 2025 15:48
-
-
Save MichalBrylka/244dc1efd053457f5a2c9936095ea4af to your computer and use it in GitHub Desktop.
Formattable number for Excel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package conditionalFormattingExcel; | |
import org.jetbrains.annotations.NotNull; | |
public sealed interface FormattableNumber permits FormattableNumber.Currency, FormattableNumber.Fixed, FormattableNumber.Percentage { | |
double rawNumber(); | |
static Fixed ofFixed(double value) { | |
return new Fixed(value); | |
} | |
static Percentage ofPercentRaw(double value) { | |
return new Percentage(value); | |
} | |
static Percentage ofPercent(double percent) { | |
return new Percentage(percent / 100.0); | |
} | |
static Currency ofCurrency(double value, String symbol) { | |
return new Currency(value, symbol); | |
} | |
default FormattableNumber withRawNumber(double rawNumber) { | |
return switch (this) { | |
case Fixed ignored -> ofFixed(rawNumber); | |
case Percentage ignored -> ofPercentRaw(rawNumber); | |
case Currency(var ignored, String currencySymbol) -> ofCurrency(rawNumber, currencySymbol); | |
}; | |
} | |
record Fixed(double rawNumber) implements FormattableNumber { | |
@Override | |
public String toString() { | |
return String.valueOf(rawNumber); | |
} | |
} | |
record Percentage(double rawNumber /*stored as fraction, e.g. 0.15 for 15%*/) implements FormattableNumber { | |
@Override | |
public String toString() { | |
return (rawNumber * 100) + "%"; | |
} | |
} | |
record Currency(double rawNumber, @NotNull String symbol) implements FormattableNumber { | |
public Currency { | |
if (symbol == null || symbol.isBlank()) | |
throw new IllegalArgumentException("Currency symbol cannot be null or empty."); | |
} | |
@Override | |
public String toString() { | |
return "%s %s".formatted(rawNumber, symbol); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package conditionalFormattingExcel; | |
import lombok.extern.slf4j.Slf4j; | |
import org.apache.poi.ss.usermodel.*; | |
import org.apache.poi.ss.util.CellAddress; | |
import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
import java.io.*; | |
import java.awt.Desktop; | |
import java.util.Map; | |
@Slf4j | |
public class Main { | |
@lombok.SneakyThrows | |
public static void main(String[] args) { | |
var tempFile = new File(System.getProperty("java.io.tmpdir"), "NumberFormats.xlsx"); | |
Workbook workbook = new XSSFWorkbook(); | |
Sheet sheet = workbook.createSheet("Formats"); | |
NumberFormat numberFormat = new NumberFormat(4, 1, 2); | |
writeFormattedCell(sheet, numberFormat, new CellAddress("A1"), FormattableNumber.ofCurrency(1234.56789, "$")); | |
writeFormattedCell(sheet, numberFormat, new CellAddress("B1"), FormattableNumber.ofPercentRaw(0.875)); | |
writeFormattedCell(sheet, numberFormat, new CellAddress("C1"), FormattableNumber.ofFixed(3.1415926)); | |
writeFormattedCell(sheet, numberFormat, new CellAddress("D1"), FormattableNumber.ofCurrency(1234.56789, "PLN")); | |
try (FileOutputStream out = new FileOutputStream(tempFile)) { | |
workbook.write(out); | |
} | |
workbook.close(); | |
if (Desktop.isDesktopSupported()) Desktop.getDesktop().open(tempFile); | |
else log.error("Desktop opening not supported."); | |
printNonEmptyCellsWithFormat(tempFile); | |
} | |
static void writeFormattedCell(Sheet sheet, NumberFormat numberFormat, CellAddress address, FormattableNumber number) { | |
Workbook workbook = sheet.getWorkbook(); | |
// Create or get row | |
Row row = sheet.getRow(address.getRow()); | |
if (row == null) row = sheet.createRow(address.getRow()); | |
// Create or get cell | |
Cell cell = row.getCell(address.getColumn()); | |
if (cell == null) cell = row.createCell(address.getColumn()); | |
// Write value | |
cell.setCellValue(number.rawNumber()); | |
// Create data format | |
DataFormat dataFormat = workbook.createDataFormat(); | |
CellStyle cellStyle = workbook.createCellStyle(); | |
String formatString = getPoiFormatString(number, numberFormat); | |
cellStyle.setDataFormat(dataFormat.getFormat(formatString)); | |
cell.setCellStyle(cellStyle); | |
} | |
private static String getPoiFormatString(FormattableNumber number, NumberFormat numberFormat) { | |
return switch (number) { | |
case FormattableNumber.Fixed ignored -> { | |
var sb = new StringBuilder(); | |
sb.append("0"); | |
if (numberFormat.fixedDecimalPlaces() > 0) | |
sb.append(".").append("0".repeat(numberFormat.fixedDecimalPlaces())); | |
yield sb.toString(); | |
} | |
case FormattableNumber.Percentage ignored -> { | |
var sb = new StringBuilder(); | |
sb.append("0"); | |
if (numberFormat.percentDecimalPlaces() > 0) | |
sb.append(".").append("0".repeat(numberFormat.percentDecimalPlaces())); | |
sb.append("%"); | |
yield sb.toString(); | |
} | |
case FormattableNumber.Currency(var ignored, String currencySymbol) -> { | |
var sb = new StringBuilder(); | |
currencySymbol = currencySymbol.replace("\"", "\"\""); | |
boolean shouldAppend = shouldAppendCurrencySymbol(currencySymbol); | |
if (!shouldAppend) | |
sb.append("\"").append(currencySymbol).append(" \""); | |
sb.append("#,##0"); | |
if (numberFormat.currencyDecimalPlaces() > 0) | |
sb.append(".").append("0".repeat(numberFormat.currencyDecimalPlaces())); | |
if (shouldAppend) | |
sb.append(" \"").append(currencySymbol).append("\""); | |
yield sb.toString(); | |
} | |
}; | |
} | |
private static boolean shouldAppendCurrencySymbol(String symbol) { | |
symbol = symbol.trim(); | |
return "zΕ".equals(symbol) || "PLN".equals(symbol); | |
// add other symbols here potentially adding locale parameter to this method | |
// For the Euro (EUR), symbol placement varies by country. | |
// In English-speaking countries, it's typically placed before the amount (e.g., β¬1,234.56), | |
// while in other European countries, it may appear after the amount with a non-breaking space (e.g., 1.234,56 β¬) | |
} | |
@lombok.SneakyThrows | |
static void printNonEmptyCellsWithFormat(File excelFile) { | |
try (FileInputStream fis = new FileInputStream(excelFile); | |
Workbook workbook = new XSSFWorkbook(fis)) { | |
for (Sheet sheet : workbook) { | |
for (Row row : sheet) { | |
for (Cell cell : row) { | |
if (cell.getCellType() == CellType.BLANK) continue; | |
// Cell address | |
String address = cell.getAddress().formatAsString(); | |
// Raw cell value | |
String value = getRawValue(cell); | |
// Format string | |
CellStyle style = cell.getCellStyle(); | |
short formatIndex = style.getDataFormat(); | |
String format = style != null ? style.getDataFormatString() : "(none)"; | |
System.out.printf("Cell %s: value = %s, format = %s, formatIndex =%s%n ", address, value, format, formatIndex); | |
} | |
} | |
} | |
} | |
} | |
private static String getRawValue(Cell cell) { | |
switch (cell.getCellType()) { | |
case STRING: | |
return cell.getStringCellValue(); | |
case NUMERIC: | |
if (DateUtil.isCellDateFormatted(cell)) { | |
return cell.getDateCellValue().toString(); | |
} else { | |
return Double.toString(cell.getNumericCellValue()); | |
} | |
case BOOLEAN: | |
return Boolean.toString(cell.getBooleanCellValue()); | |
case FORMULA: | |
return cell.getCellFormula(); | |
case ERROR: | |
return Byte.toString(cell.getErrorCellValue()); | |
default: | |
return "(unknown)"; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package conditionalFormattingExcel; | |
import com.fasterxml.jackson.core.*; | |
import com.fasterxml.jackson.databind.*; | |
import com.fasterxml.jackson.databind.node.IntNode; | |
import java.io.IOException; | |
import java.util.Iterator; | |
import java.util.Set; | |
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = NumberFormat.NumberFormatSerializer.class) | |
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = NumberFormat.NumberFormatDeserializer.class) | |
public record NumberFormat(int fixedDecimalPlaces, int percentDecimalPlaces, int currencyDecimalPlaces) { | |
public NumberFormat { | |
if (fixedDecimalPlaces < 0) { | |
throw new IllegalArgumentException("fixedDecimalPlaces must be non-negative"); | |
} | |
if (percentDecimalPlaces < 0) { | |
throw new IllegalArgumentException("percentDecimalPlaces must be non-negative"); | |
} | |
if (currencyDecimalPlaces < 0) { | |
throw new IllegalArgumentException("currencyDecimalPlaces must be non-negative"); | |
} | |
} | |
public NumberFormat(Integer fixedDecimalPlaces, Integer percentDecimalPlaces, Integer currencyDecimalPlaces) { | |
this( | |
fixedDecimalPlaces == null ? 2 : fixedDecimalPlaces, | |
percentDecimalPlaces == null ? 0 : percentDecimalPlaces, | |
currencyDecimalPlaces == null ? 2 : currencyDecimalPlaces | |
); | |
} | |
public static final class NumberFormatSerializer extends JsonSerializer<NumberFormat> { | |
@Override | |
public void serialize(NumberFormat value, JsonGenerator gen, SerializerProvider serializers) throws IOException { | |
if (value == null) { | |
gen.writeNull(); | |
return; | |
} | |
gen.writeStartObject(); | |
gen.writeNumberField("#", value.fixedDecimalPlaces()); | |
gen.writeNumberField("%", value.percentDecimalPlaces()); | |
gen.writeNumberField("$", value.currencyDecimalPlaces()); | |
gen.writeEndObject(); | |
} | |
} | |
public static final class NumberFormatDeserializer extends JsonDeserializer<NumberFormat> { | |
private static final Set<String> ALLOWED_KEYS = Set.of("#", "%", "$"); | |
@Override | |
public NumberFormat deserialize(JsonParser p, DeserializationContext ctx) throws IOException { | |
JsonNode node = p.readValueAsTree(); | |
if (node.isNull()) | |
return null; | |
if (!node.isObject()) | |
throw JsonMappingException.from(p, "Expected JSON object for Format"); | |
// Validate no unknown fields exist | |
Iterator<String> fieldNames = node.fieldNames(); | |
while (fieldNames.hasNext()) { | |
String field = fieldNames.next(); | |
if (!ALLOWED_KEYS.contains(field)) | |
throw JsonMappingException.from(p, "Unknown field: \"" + field + "\". Allowed fields are: " + ALLOWED_KEYS); | |
} | |
Integer fixed = node.get("#") instanceof IntNode fixedNode ? fixedNode.intValue() : null; | |
Integer percent = node.get("%") instanceof IntNode percentNode ? percentNode.intValue() : null; | |
Integer currency = node.get("$") instanceof IntNode currencyNode ? currencyNode.intValue() : null; | |
return new NumberFormat(fixed, percent, currency); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package conditionalFormattingExcel; | |
import com.fasterxml.jackson.databind.JsonMappingException; | |
import com.fasterxml.jackson.databind.ObjectMapper; | |
import org.junit.jupiter.api.DisplayName; | |
import org.junit.jupiter.params.ParameterizedTest; | |
import org.junit.jupiter.params.provider.Arguments; | |
import org.junit.jupiter.params.provider.MethodSource; | |
import java.util.stream.Stream; | |
import static org.assertj.core.api.Assertions.*; | |
import static org.junit.jupiter.params.provider.Arguments.of; | |
public class NumberFormatTest { | |
private static final ObjectMapper MAPPER = new ObjectMapper(); | |
static Stream<Arguments> validSerializationCases() { | |
return Stream.of( | |
of(new NumberFormat(11, 22, 33), """ | |
{ | |
"#": 11, | |
"%": 22, | |
"$": 33 | |
} | |
"""), | |
of(new NumberFormat(3, 1, 5), """ | |
{ | |
"#": 3, | |
"%": 1, | |
"$": 5 | |
} | |
""") | |
); | |
} | |
@ParameterizedTest(name = "β should serialize {0} correctly") | |
@MethodSource("validSerializationCases") | |
void shouldSerialize(NumberFormat input, String expectedJson) throws Exception { | |
String actualJson = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(input); | |
assertThat(actualJson).isEqualToIgnoringWhitespace(expectedJson); | |
} | |
static Stream<Arguments> validDeserializationCases() { | |
return Stream.of( | |
of(""" | |
{} | |
""", new NumberFormat(null, null, null)), | |
of(""" | |
{ | |
"#": 44 | |
} | |
""", new NumberFormat(44, null, null)), | |
of(""" | |
{ | |
"%": 11, | |
"$": 55 | |
} | |
""", new NumberFormat(null, 11, 55)), | |
of(""" | |
{ | |
"#": 3, | |
"%": 1, | |
"$": 0 | |
} | |
""", new NumberFormat(3, 1, 0)), | |
of(""" | |
{ | |
"#": null, | |
"%": null, | |
"$": null | |
} | |
""", new NumberFormat(null, null, null)), | |
of(""" | |
{ "%": 22, "$": 33 } | |
""", new NumberFormat(null, 22, 33)), | |
of(""" | |
{ "#": 11, "$": 33 } | |
""", new NumberFormat(11, null, 33)), | |
of(""" | |
{ "#": 11, "%": 22 } | |
""", new NumberFormat(11, 22, null)) | |
); | |
} | |
@ParameterizedTest(name = "β should deserialize to {1}") | |
@MethodSource("validDeserializationCases") | |
void shouldDeserialize(String json, NumberFormat expected) throws Exception { | |
NumberFormat actual = MAPPER.readValue(json, NumberFormat.class); | |
assertThat(actual).isEqualTo(expected); | |
} | |
static Stream<Arguments> invalidDeserializationCases() { | |
return Stream.of( | |
of(""" | |
{ | |
"#": 2, | |
"unknown": 7 | |
} | |
""", "Unknown field: \"unknown\". Allowed fields are:"), | |
of(""" | |
{ | |
"π«": 9 | |
} | |
""", "Unknown field: \"π«\". Allowed fields are:") | |
); | |
} | |
@ParameterizedTest(name = "β should fail deserializing invalid input: {1}") | |
@MethodSource("invalidDeserializationCases") | |
@DisplayName("β should throw exception on invalid JSON") | |
void shouldFailDeserialization(String invalidJson, String expectedError) { | |
assertThatThrownBy(() -> MAPPER.readValue(invalidJson, NumberFormat.class)) | |
.isInstanceOf(JsonMappingException.class) | |
.hasMessageContaining(expectedError); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment