Overview
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
orfreemarker.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
andAliasTemplateDateFormatFactory
. 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 (likeint
-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 theTemplateModel
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 theformat
method in the formatter class so that it returns aTemplateMarkupOutputModel
instead of aString
. (You shouldn't just return the markup asString
, 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:
${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:
${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:
${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:
${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:
${weight}
we want to see this:
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");