Skip to content

Instantly share code, notes, and snippets.

@MichalBrylka
Last active June 3, 2025 15:48
Show Gist options
  • Save MichalBrylka/244dc1efd053457f5a2c9936095ea4af to your computer and use it in GitHub Desktop.
Save MichalBrylka/244dc1efd053457f5a2c9936095ea4af to your computer and use it in GitHub Desktop.
Formattable number for Excel
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);
}
}
}
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)";
}
}
}
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);
}
}
}
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