Custom number and date/time formats

Overview

Note:

Custom formats (of the kind described here) exists since FreeMarker 2.3.24.

FreeMarker allows you to define your own number and date/time/datetime formats, and associate a name to them. This mechanism has several applications:

  • Custom formatter algorithms: You can use your own formatter algorithm instead of relying on those provided by FreeMarker. For this, implement freemarker.core.TemplateNumberFormatFactory or freemarker.core.TemplateDateFormatFactory. You will find a few examples of this below.

  • Aliasing: You can give application-specific names (like "price", "weight", "fileDate", "logEventTime", etc.) to other formats by using AliasTemplateNumberFormatFactory and AliasTemplateDateFormatFactory. Thus templates can just refer to that name, like in ${lastModified?string.@fileDate}, instead of specifying the format directly. Thus the formats can be specified on a single central place (where you configure FreeMarker), instead of being specified repeatedly in templates. Also thus template authors don't have to enter complex and hard to remember formatting patterns. See example below.

  • Model-sensitive formatting: Applications can put custom freemarker.TemplateModel-s into the data-model instead of dropping plain values (like int-s, double-s, etc.) into it, to attach rendering-related information to the value. Custom formatters can utilize this information (for example, to show the unit after numbers), as they receive the TemplateModel itself, not the wrapped raw value. See example below.

  • Format that prints markup instead of plain text: You might want to use HTML tags (or other markup) in the formatted values, such as coloring negative numbers to red or using HTML sup element for exponents. This is possible if you write a custom format as shown in previous cases, but override the format method in the formatter class so that it returns a TemplateMarkupOutputModel instead of a String. (You shouldn't just return the markup as String, as then it might will be escaped; see auto-escaping.)

Custom formats can be registered with the custom_number_formats and custom_date_formats configuration settings. After that, anywhere where you can specify formats with a String, now you can refer to your custom format as "@name". So for example, if you have registered your number format implementation with name "smart", then you could set the number_format setting (Configurable.setNumberFormat(String)) to "@smart", or issue ${n?string.@smart} or <#setting number_format="@smart"> in a template. Furthermore, you can define parameters for your custom format, like "@smart 2", and the interpretation of the parameters is up to your formatter implementation.

Simple custom number format example

This custom number format shows numbers in hexadecimal form:

package com.example;

import java.util.Locale;

import freemarker.template.TemplateModelException;
import freemarker.template.TemplateNumberModel;
import freemarker.template.utility.NumberUtil;

public class HexTemplateNumberFormatFactory extends TemplateNumberFormatFactory {

    public static final HexTemplateNumberFormatFactory INSTANCE
            = new HexTemplateNumberFormatFactory();

    private HexTemplateNumberFormatFactory() {
        // Defined to decrease visibility
    }

    @Override
    public TemplateNumberFormat get(String params, Locale locale, Environment env)
            throws InvalidFormatParametersException {
        TemplateFormatUtil.checkHasNoParameters(params);
        return HexTemplateNumberFormat.INSTANCE;
    }

    private static class HexTemplateNumberFormat extends TemplateNumberFormat {

        private static final HexTemplateNumberFormat INSTANCE = new HexTemplateNumberFormat();

        private HexTemplateNumberFormat() { }

        @Override
        public String formatToPlainText(TemplateNumberModel numberModel)
                throws UnformattableValueException, TemplateModelException {
            Number n = TemplateFormatUtil.getNonNullNumber(numberModel);
            try {
                return Integer.toHexString(NumberUtil.toIntExact(n));
            } catch (ArithmeticException e) {
                throw new UnformattableValueException(n + " doesn't fit into an int");
            }
        }

        @Override
        public boolean isLocaleBound() {
            return false;
        }

        @Override
        public String getDescription() {
            return "hexadecimal int";
        }

    }

}

We register the above format with name "hex":

// Where you initalize the application-wide Configuration singleton:
Configuration cfg = ...;
...
Map<String, TemplateNumberFormatFactory> customNumberFormats = ...;
...
customNumberFormats.put("hex", HexTemplateNumberFormatFactory.INSTANCE);
...
cfg.setCustomNumberFormats(customNumberFormats);

Now we can use this format in templates:

Template
${x?string.@hex}

or even set it as the default number format:

cfg.setNumberFormat("@hex");

Advanced custom number format example

This is a more complex custom number format that shows how to deal with parameters in the format string, also how to delegate to another format:

package com.example;

import java.util.Locale;

import freemarker.template.TemplateModelException;
import freemarker.template.TemplateNumberModel;
import freemarker.template.utility.NumberUtil;
import freemarker.template.utility.StringUtil;

/**
 * Shows a number in base N number system. Can only format numbers that fit into an {@code int},
 * however, optionally you can specify a fallback format. This format has one required parameter,
 * the numerical system base. That can be optionally followed by "|" and a fallback format.
 */
public class BaseNTemplateNumberFormatFactory extends TemplateNumberFormatFactory {

    public static final BaseNTemplateNumberFormatFactory INSTANCE
            = new BaseNTemplateNumberFormatFactory();

    private BaseNTemplateNumberFormatFactory() {
        // Defined to decrease visibility
    }

    @Override
    public TemplateNumberFormat get(String params, Locale locale, Environment env)
            throws InvalidFormatParametersException {
        TemplateNumberFormat fallbackFormat;
        {
            int barIdx = params.indexOf('|');
            if (barIdx != -1) {
                String fallbackFormatStr = params.substring(barIdx + 1);
                params = params.substring(0, barIdx);
                try {
                    fallbackFormat = env.getTemplateNumberFormat(fallbackFormatStr, locale);
                } catch (TemplateValueFormatException e) {
                    throw new InvalidFormatParametersException(
                            "Couldn't get the fallback number format (specified after the \"|\"), "
                            + StringUtil.jQuote(fallbackFormatStr) + ". Reason: " + e.getMessage(),
                            e);
                }
            } else {
                fallbackFormat = null;
            }
        }

        int base;
        try {
            base = Integer.parseInt(params);
        } catch (NumberFormatException e) {
            if (params.length() == 0) {
                throw new InvalidFormatParametersException(
                        "A format parameter is required to specify the numerical system base.");
            }
            throw new InvalidFormatParametersException(
                    "The format paramter must be an integer, but was (shown quoted): "
                    + StringUtil.jQuote(params));
        }
        if (base < 2) {
            throw new InvalidFormatParametersException("A base must be at least 2.");
        }
        return new BaseNTemplateNumberFormat(base, fallbackFormat);
    }

    private static class BaseNTemplateNumberFormat extends TemplateNumberFormat {

        private final int base;
        private final TemplateNumberFormat fallbackFormat;

        private BaseNTemplateNumberFormat(int base, TemplateNumberFormat fallbackFormat) {
            this.base = base;
            this.fallbackFormat = fallbackFormat;
        }

        @Override
        public String formatToPlainText(TemplateNumberModel numberModel)
                throws TemplateModelException, TemplateValueFormatException {
            Number n = TemplateFormatUtil.getNonNullNumber(numberModel);
            try {
                return Integer.toString(NumberUtil.toIntExact(n), base);
            } catch (ArithmeticException e) {
                if (fallbackFormat == null) {
                    throw new UnformattableValueException(
                            n + " doesn't fit into an int, and there was no fallback format "
                            + "specified.");
                } else {
                    return fallbackFormat.formatToPlainText(numberModel);
                }
            }
        }

        @Override
        public boolean isLocaleBound() {
            return false;
        }

        @Override
        public String getDescription() {
            return "base " + base;
        }

    }

}

We register the above format with name "base":

// Where you initalize the application-wide Configuration singleton:
Configuration cfg = ...;
...
Map<String, TemplateNumberFormatFactory> customNumberFormats = ...;
...
customNumberFormats.put("base", BaseNTemplateNumberFormatFactory.INSTANCE);
...
cfg.setCustomNumberFormats(customNumberFormats);

Now we can use this format in templates:

Template
${x?string.@base_8}

Above there the parameter string was "8", as FreeMarker allows separating that from the format name with _ instead of whitespace, so that you don't have to write the longer n?string["@base 8"] form.

Of course, we could also set this as the default number format like:

cfg.setNumberFormat("@base 8");

Here's an example of using the a fallback number format (which is "0.0###"):

cfg.setNumberFormat("@base 8|0.0###");

Note that this functionality, with the | syntax and all, is purely implemented in the example code earlier.

Custom date/time format example

This simple date format formats the date/time value to the milliseconds since the epoch:

package com.example;

import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;

import freemarker.template.TemplateDateModel;
import freemarker.template.TemplateModelException;

public class EpochMillisTemplateDateFormatFactory extends TemplateDateFormatFactory {

    public static final EpochMillisTemplateDateFormatFactory INSTANCE
            = new EpochMillisTemplateDateFormatFactory();

    private EpochMillisTemplateDateFormatFactory() {
        // Defined to decrease visibility
    }

    @Override
    public TemplateDateFormat get(String params, int dateType,
            Locale locale, TimeZone timeZone, boolean zonelessInput,
            Environment env)
            throws InvalidFormatParametersException {
        TemplateFormatUtil.checkHasNoParameters(params);
        return EpochMillisTemplateDateFormat.INSTANCE;
    }

    private static class EpochMillisTemplateDateFormat extends TemplateDateFormat {

        private static final EpochMillisTemplateDateFormat INSTANCE
                = new EpochMillisTemplateDateFormat();

        private EpochMillisTemplateDateFormat() { }

        @Override
        public String formatToPlainText(TemplateDateModel dateModel)
                throws UnformattableValueException, TemplateModelException {
            return String.valueOf(TemplateFormatUtil.getNonNullDate(dateModel).getTime());
        }

        @Override
        public boolean isLocaleBound() {
            return false;
        }

        @Override
        public boolean isTimeZoneBound() {
            return false;
        }

        @Override
        public Date parse(String s, int dateType) throws UnparsableValueException {
            try {
                return new Date(Long.parseLong(s));
            } catch (NumberFormatException e) {
                throw new UnparsableValueException("Malformed long");
            }
        }

        @Override
        public String getDescription() {
            return "millis since the epoch";
        }

    }

}

We register the above format with name "epoch":

// Where you initalize the application-wide Configuration singleton:
Configuration cfg = ...;
...
Map<String, TemplateDateFormatFactory> customDateFormats = ...;
...
customDateFormats.put("epoch", EpochMillisTemplateDateFormatFactory.INSTANCE);
...
cfg.setCustomDateFormats(customDateFormats);

Now we can use this format in templates:

Template
${t?string.@epoch}

Of course, we could also set this as the default date-time format like:

cfg.setDateTimeFormat("@epoch");

For a more complex that for example uses format parameters, refer to the advanced number format example. Doing that with date formats is very similar.

Alias format example

In this example we specify some number formats and date formats that are aliases to another format:

// Where you initalize the application-wide Configuration singleton:
Configuration cfg = ...;

Map<String, TemplateNumberFormatFactory> customNumberFormats
        = new HashMap<String, TemplateNumberFormatFactory>();
customNumberFormats.put("price", new AliasTemplateNumberFormatFactory(",000.00"));
customNumberFormats.put("weight",
        new AliasTemplateNumberFormatFactory("0.##;; roundingMode=halfUp"));
cfg.setCustomNumberFormats(customNumberFormats);

Map<String, TemplateDateFormatFactory> customDateFormats
        = new HashMap<String, TemplateDateFormatFactory>();
customDateFormats.put("fileDate", new AliasTemplateDateFormatFactory("dd/MMM/yy hh:mm a"));
customDateFormats.put("logEventTime", new AliasTemplateDateFormatFactory("iso ms u"));
cfg.setCustomDateFormats(customDateFormats);

So now you can do this in a template:

Template
${product.price?string.@price}
${product.weight?string.@weight}
${lastModified?string.@fileDate}
${lastError.timestamp?string.@logEventTime}

Note that the constructor parameter of AliasTemplateNumberFormatFactory can naturally refer to a custom format too:

Map<String, TemplateNumberFormatFactory> customNumberFormats
        = new HashMap<String, TemplateNumberFormatFactory>();
customNumberFormats.put("base", BaseNTemplateNumberFormatFactory.INSTANCE);
customNumberFormats.put("oct", new AliasTemplateNumberFormatFactory("@base 8"));
cfg.setCustomNumberFormats(customNumberFormats);

So now n?string.@oct will format the number to octal form.

Model-aware format example

In this example we specify a number format that automatically show the unit after the number if that was put into the data-model as UnitAwareTemplateNumberModel. First let's see UnitAwareTemplateNumberModel:

package com.example;

import freemarker.template.TemplateModelException;
import freemarker.template.TemplateNumberModel;

public class UnitAwareTemplateNumberModel implements TemplateNumberModel {

    private final Number value;
    private final String unit;

    public UnitAwareTemplateNumberModel(Number value, String unit) {
        this.value = value;
        this.unit = unit;
    }

    @Override
    public Number getAsNumber() throws TemplateModelException {
        return value;
    }

    public String getUnit() {
        return unit;
    }

}

When you fill the data-model, you could do something like this:

Map<String, Object> dataModel = new HashMap<>();
dataModel.put("weight", new UnitAwareTemplateNumberModel(1.5, "kg"));
// Rather than just: dataModel.put("weight", 1.5);

Then if we have this in the template:

Template
${weight}

we want to see this:

Output
1.5 kg

To achieve that, we define this custom number format:

package com.example;

import java.util.Locale;

import freemarker.core.Environment;
import freemarker.core.TemplateNumberFormat;
import freemarker.core.TemplateNumberFormatFactory;
import freemarker.core.TemplateValueFormatException;
import freemarker.template.TemplateModelException;
import freemarker.template.TemplateNumberModel;

/**
 * A number format that takes any other number format as parameter (specified as a string, as
 * usual in FreeMarker), then if the model is a {@link UnitAwareTemplateNumberModel}, it  shows
 * the unit after the number formatted with the other format, otherwise it just shows the formatted
 * number without unit.
 */
public class UnitAwareTemplateNumberFormatFactory extends TemplateNumberFormatFactory {

    public static final UnitAwareTemplateNumberFormatFactory INSTANCE
            = new UnitAwareTemplateNumberFormatFactory();

    private UnitAwareTemplateNumberFormatFactory() {
        // Defined to decrease visibility
    }

    @Override
    public TemplateNumberFormat get(String params, Locale locale, Environment env)
            throws TemplateValueFormatException {
        return new UnitAwareNumberFormat(env.getTemplateNumberFormat(params, locale));
    }

    private static class UnitAwareNumberFormat extends TemplateNumberFormat {

        private final TemplateNumberFormat innerFormat;

        private UnitAwareNumberFormat(TemplateNumberFormat innerFormat) {
            this.innerFormat = innerFormat;
        }

        @Override
        public String formatToPlainText(TemplateNumberModel numberModel)
                throws TemplateModelException, TemplateValueFormatException {
            String innerResult = innerFormat.formatToPlainText(numberModel);
            return numberModel instanceof UnitAwareTemplateNumberModel
                    ? innerResult + " " + ((UnitAwareTemplateNumberModel) numberModel).getUnit()
                    : innerResult;
        }

        @Override
        public boolean isLocaleBound() {
            return innerFormat.isLocaleBound();
        }

        @Override
        public String getDescription() {
            return "unit-aware " + innerFormat.getDescription();
        }

    }

}

Finally, we set the above custom format as the default number format:

// Where you initalize the application-wide Configuration singleton:
Configuration cfg = ...;

Map<String, TemplateNumberFormatFactory> customNumberFormats = new HashMap<>();
customNumberFormats.put("ua", UnitAwareTemplateNumberFormatFactory.INSTANCE);
cfg.setCustomNumberFormats(customNumberFormats);

// Note: "0.####;; roundingMode=halfUp" is a standard format specified in FreeMarker.
cfg.setNumberFormat("@ua 0.####;; roundingMode=halfUp");