The Unicode Common Locale Data Repository, commonly known as the CLDR, is an authoritative repository for localized information of all kinds, including translations.
Since Java 9, Java SE has used the CLDR for a lot of (if not all) localization functionality. But I am not aware of any localized formatting of java.time.Period instances using CLDR data files.
The CLDR has its own Java tools, but I experimented with the compiled .jar for hours and could not figure out how to use it. I couldn’t find documentation beyond the (very short) command line help. If it has an API for formatting periods, I would definitely favor using that.
But the CLDR data files are still the best place to get this information. (They can also be cloned from https://github.com/unicode-org/cldr) So, with that in mind, I wrote a (rather long) class to format Period instances using CLDR data files.
There are a few steps involved:
- First, code must determine the language cardinality for a particular number. In English, there are only two choices: singular and plural. Other languages can be more complex. The
common/supplemental/plurals.xml
file in the CLDR contains all of this data for all locales; its format is documented on unicode.org.
- Most localized data is in the
common/main
directory of the CLDR, as .xml files named by locale. The elements related to periods are in <ldml>
→ <units>
→ <unitLength type="long">
→ <unit type="duration-week">
, as well as type="duration-year", type="duration-month", and type="duration-day". Within the unit element are <unitPattern>
elements, each with a count
attribute referencing the cardinality described above.
- Finally, the same locale-specific file in
common/main
contains <listPattern>
elements which describe how to format a list of items, including <listPattern type="unit">
for assembling a list of period parts. The use of this data is described here.
Note that Period.parse accepts the ISO 8601 format, including weeks, but the Period class itself does not keep track of weeks, so if output with weeks is desired, code must divide the Period’s days by 7.
Here is what I came up with. The parsing is not robust and makes a lot of assumptions about correct CLDR data. There are probably ways to speed up the reading of the XML files, such as caching recently used ones, so consider this a proof of concept:
public class PeriodFormatter {
public enum Count {
EXACTLY0("0"),
EXACTLY1("1"),
ZERO,
ONE,
TWO,
FEW,
MANY,
OTHER;
private final String attributeValue;
Count() {
this.attributeValue = name().toLowerCase(Locale.US);
}
Count(String attributeValue) {
this.attributeValue = attributeValue;
}
String attributeValue() {
return attributeValue;
}
static Count forAttributeValue(String attrValue) {
for (Count count : values()) {
if (count.attributeValue.equals(attrValue)) {
return count;
}
}
throw new IllegalArgumentException(
"No Count with attribute value \"" + attrValue + "\"");
}
}
private static class Range {
final long start;
final long end;
Range(long value) {
this(value, value);
}
Range(long start,
long end) {
this.start = start;
this.end = end;
}
boolean contains(long value) {
return value >= start && value <= end;
}
boolean contains(double value) {
return value >= start && value <= end;
}
}
private enum Operand {
ABSOLUTE_VALUE("n"),
INTEGER_PART("i"),
FRACTIONAL_PRECISION("v"),
FRACTIONAL_PRECISION_TRIMMED("w"),
FRACTIONAL_PART("f"),
FRACTIONAL_PART_TRIMMED("t"),
EXPONENT_PART("c", "e");
final String[] tokens;
private Operand(String... tokens) {
this.tokens = tokens;
}
static Operand forToken(String token) {
for (Operand op : values()) {
if (Arrays.asList(op.tokens).contains(token)) {
return op;
}
}
throw new IllegalArgumentException(
"No Operand for token \"" + token + "\"");
}
}
private static class Expression {
final Operand operand;
final Long modValue;
Expression(Operand operand,
Long modValue) {
this.operand = operand;
this.modValue = modValue;
}
double evaluate(BigDecimal value) {
switch (operand) {
case ABSOLUTE_VALUE:
return Math.abs(value.doubleValue());
case INTEGER_PART:
return value.longValue();
case FRACTIONAL_PRECISION:
return Math.max(value.scale(), 0);
case FRACTIONAL_PRECISION_TRIMMED:
return Math.max(value.stripTrailingZeros().scale(), 0);
case FRACTIONAL_PART:
BigDecimal frac = value.remainder(BigDecimal.ONE);
frac = frac.movePointRight(Math.max(0, frac.scale()));
return frac.doubleValue();
case FRACTIONAL_PART_TRIMMED:
BigDecimal trimmed =
value.stripTrailingZeros().remainder(BigDecimal.ONE);
trimmed = trimmed.movePointRight(
Math.max(0, trimmed.scale()));
return trimmed.longValue();
case EXPONENT_PART:
String expStr = String.format("%e", value);
return Long.parseLong(
expStr.substring(expStr.indexOf('e') + 1));
default:
break;
}
throw new RuntimeException("Unknown operand " + operand);
}
}
private static abstract class Relation {
boolean negated;
Expression expr;
abstract boolean matches(BigDecimal value);
final boolean matchIfIntegral(BigDecimal value,
LongPredicate test) {
double evaluatedValue = expr.evaluate(value);
long rounded = Math.round(evaluatedValue);
return Math.abs(evaluatedValue - rounded) < 0.000001
&& test.test(rounded);
}
}
private static class IsRelation
extends Relation {
long value;
@Override
boolean matches(BigDecimal value) {
return matchIfIntegral(value, n -> n == this.value);
}
}
private static class InRelation
extends Relation {
final List<Range> ranges = new ArrayList<>();
@Override
boolean matches(BigDecimal value) {
return ranges.stream().anyMatch(
range -> matchIfIntegral(value, n -> range.contains(n)));
}
}
private static class WithinRelation
extends Relation {
final List<Range> ranges = new ArrayList<>();
@Override
boolean matches(BigDecimal value) {
return ranges.stream().anyMatch(r -> r.contains(value.longValue()));
}
}
private static class Condition {
final List<Relation> relations = new ArrayList<>();
boolean matches(BigDecimal value) {
return relations.stream().allMatch(r -> r.matches(value));
}
}
private static class AndConditionSequence {
final List<Condition> conditions = new ArrayList<>();
boolean matches(BigDecimal value) {
return conditions.stream().allMatch(c -> c.matches(value));
}
}
private static class PluralRule {
final Count count;
final List<AndConditionSequence> conditions = new ArrayList<>();
PluralRule(String countSpec,
String ruleSpec) {
this.count = Count.forAttributeValue(countSpec);
Scanner scanner = new Scanner(ruleSpec);
AndConditionSequence andSequence = new AndConditionSequence();
Condition condition = new Condition();
andSequence.conditions.add(condition);
this.conditions.add(andSequence);
while (true) {
String token = scanner.findWithinHorizon("\\S", 0);
if (token.equals("@")) {
// Ignore samples.
break;
}
Operand operand = Operand.forToken(token);
Long modValue = null;
token = scanner.findWithinHorizon(
"mod|%|is|in|within|!?=|not", 0);
if (token.equals("mod") || token.equals("%")) {
modValue = Long.valueOf(
scanner.findWithinHorizon("\\d+", 0));
token = scanner.findWithinHorizon(
"is|in|within|!?=|not", 0);
}
Relation relation;
boolean negated = false;
if (token.equals("not")) {
token = scanner.findWithinHorizon("in|within", 0);
if (token.equals("within")) {
WithinRelation within = new WithinRelation();
relation = within;
within.negated = true;
parseRanges(within.ranges, scanner);
} else {
InRelation in = new InRelation();
relation = in;
in.negated = true;
parseRanges(in.ranges, scanner);
}
relation.negated = true;
} else if (token.equals("is")) {
IsRelation is = new IsRelation();
relation = is;
token = scanner.findWithinHorizon("not|\\d+", 0);
if (token.equals("not")) {
is.negated = true;
token = scanner.findWithinHorizon("\\d+", 0);
}
is.value = Long.valueOf(token);
} else if (token.endsWith("=")) {
InRelation in = new InRelation();
relation = in;
in.negated = token.startsWith("!");
parseRanges(in.ranges, scanner);
} else {
throw new RuntimeException(
"Unexpected token '" + token + "'");
}
relation.expr = new Expression(operand, modValue);
condition.relations.add(relation);
if (!scanner.hasNext("and|or")) {
break;
}
token = scanner.next();
if (token.equals("and")) {
condition = new Condition();
andSequence.conditions.add(condition);
} else {
andSequence = new AndConditionSequence();
this.conditions.add(andSequence);
}
}
}
static void parseRanges(Collection<Range> ranges,
Scanner scanner) {
boolean first = true;
while (true) {
if (!first) {
if (!scanner.hasNext(",.*")) {
break;
}
scanner.findWithinHorizon(",", 0);
}
String token =
scanner.findWithinHorizon("\\d+(?:\\.\\.\\d+)?", 0);
int period = token.indexOf('.');
if (period > 0) {
long start = Long.parseLong(token.substring(0, period));
long end = Long.parseLong(token.substring(period + 2));
ranges.add(new Range(start, end));
} else {
long value = Long.parseLong(token);
ranges.add(new Range(value));
}
first = false;
}
}
boolean matches(BigDecimal value) {
return conditions.stream().anyMatch(c -> c.matches(value));
}
}
private static final Map<Locale, List<PluralRule>> pluralRules =
new HashMap<>();
private static final Map<Locale, Path> dataFiles = new HashMap<>();
private static final Path cldrDir;
static {
String dir = System.getProperty("cldr");
if (dir == null) {
throw new RuntimeException(
"\"cldr\" system property must be set to root directory"
+ " of CLDR data. That data can be downloaded from"
+ "https://cldr.unicode.org/index/downloads or"
+ "https://github.com/unicode-org/cldr .");
}
cldrDir = Paths.get(dir);
}
private final XPath xpath;
public PeriodFormatter() {
this.xpath = XPathFactory.newInstance().newXPath();
}
private static InputSource createSource(Path path) {
return new InputSource(path.toUri().toASCIIString());
}
private Count countFor(BigDecimal amount,
Locale locale) {
synchronized (pluralRules) {
if (pluralRules.isEmpty()) {
Path pluralsFile = cldrDir.resolve(
Paths.get("common", "supplemental", "plurals.xml"));
NodeList rulesElements;
try {
rulesElements = (NodeList) xpath.evaluate(
"//plurals[@type='cardinal']/pluralRules",
createSource(pluralsFile),
XPathConstants.NODESET);
} catch (XPathException e) {
throw new RuntimeException(e);
}
int count = rulesElements.getLength();
for (int i = 0; i < count; i++) {
Element rulesElement = (Element) rulesElements.item(i);
String[] localeNames =
rulesElement.getAttribute("locales").split("\\s+");
NodeList ruleElements =
rulesElement.getElementsByTagName("pluralRule");
int ruleCount = ruleElements.getLength();
List<PluralRule> ruleList = new ArrayList<>(ruleCount);
for (int j = 0; j < ruleCount; j++) {
Element ruleElement = (Element) ruleElements.item(j);
ruleList.add(new PluralRule(
ruleElement.getAttribute("count"),
ruleElement.getTextContent()));
}
for (String localeName : localeNames) {
localeName = localeName.replace('_', '-');
pluralRules.put(
Locale.forLanguageTag(localeName),
ruleList);
}
}
}
}
Locale availableLocale = Locale.lookup(
Locale.LanguageRange.parse(locale.toLanguageTag()),
pluralRules.keySet());
if (availableLocale == null) {
availableLocale = Locale.ROOT;
}
List<PluralRule> rulesOfLocale = pluralRules.get(availableLocale);
for (PluralRule rule : rulesOfLocale) {
if (rule.matches(amount)) {
return rule.count;
}
}
throw new IllegalArgumentException("No plural rule matches " + amount);
}
private static List<Locale> listWithFallbacks(Locale locale) {
Collection<Locale> locales = new LinkedHashSet<>();
locales.add(locale);
Locale.Builder builder = new Locale.Builder();
builder.setLanguageTag(locale.toLanguageTag());
locales.add(builder.setVariant(null).build());
locales.add(builder.setRegion(null).build());
locales.add(builder.setScript(null).build());
locales.add(Locale.ROOT);
return new ArrayList<>(locales);
}
private Iterable<Path> dataFilesFor(Locale locale) {
synchronized (dataFiles) {
if (dataFiles.isEmpty()) {
Path dataFileDir = cldrDir.resolve(Paths.get("common", "main"));
try (DirectoryStream<Path> dir =
Files.newDirectoryStream(dataFileDir, "*.xml")) {
for (Path dataFile : dir) {
InputSource source = createSource(dataFile);
NodeList identityElements = (NodeList)
xpath.evaluate("/ldml/identity", source,
XPathConstants.NODESET);
Element identity = (Element) identityElements.item(0);
String lang =
xpath.evaluate("language/@type", identity);
String script =
xpath.evaluate("script/@type", identity);
String region =
xpath.evaluate("territory/@type", identity);
String variant =
xpath.evaluate("variant/@type", identity);
Locale dataFileLocale;
if (lang.equals("root")) {
dataFileLocale = Locale.ROOT;
} else {
Locale.Builder builder = new Locale.Builder();
builder.setLanguage(lang);
builder.setScript(script);
builder.setRegion(region);
builder.setVariant(variant);
dataFileLocale = builder.build();
}
dataFiles.put(dataFileLocale, dataFile);
}
} catch (IOException | XPathException e) {
throw new RuntimeException(e);
}
}
}
Collection<Locale> locales = listWithFallbacks(locale);
Collection<Path> dataFilesForLocale = new ArrayList<>();
for (Locale localeToCheck : locales) {
Path dataFile = dataFiles.get(localeToCheck);
if (dataFile != null) {
dataFilesForLocale.add(dataFile);
}
}
return dataFilesForLocale;
}
private Optional<Element> locateElement(Object source,
String path) {
try {
Element element = null;
while (true) {
NodeList elements;
if (source instanceof InputSource) {
elements = (NodeList) xpath.evaluate(path,
(InputSource) source, XPathConstants.NODESET);
} else {
elements = (NodeList) xpath.evaluate(path,
source, XPathConstants.NODESET);
}
if (elements.getLength() < 1) {
break;
}
element = (Element) elements.item(0);
NodeList list = (NodeList) xpath.evaluate("alias", element,
XPathConstants.NODESET);
if (list.getLength() == 0) {
// No more aliases to follow, so we've found our target.
break;
}
Element alias = (Element) list.item(0);
path = alias.getAttribute("path");
source = element;
}
return Optional.ofNullable(element);
} catch (XPathException e) {
throw new RuntimeException(e);
}
}
private Optional<String> readElement(Iterable<? extends Path> dataFiles,
String... paths)
throws XPathException {
Optional<Element> element = Optional.empty();
for (Path dataFile : dataFiles) {
Object source = createSource(dataFile);
element = Optional.empty();
for (String path : paths) {
element = locateElement(source, path);
if (!element.isPresent()) {
break;
}
source = element.get();
}
if (element.isPresent()) {
break;
}
}
return element.map(Element::getTextContent);
}
private String format(ChronoUnit units,
BigDecimal amount,
Locale locale) {
String type = null;
switch (units) {
case YEARS:
type = "duration-year";
break;
case MONTHS:
type = "duration-month";
break;
case WEEKS:
type = "duration-week";
break;
case DAYS:
type = "duration-day";
break;
default:
throw new IllegalArgumentException(
"Valid units are YEARS, MONTHS, WEEKS, and DAYS.");
}
Count count = countFor(amount, locale);
try {
Optional<String> formatPattern = readElement(dataFilesFor(locale),
"/ldml/units" +
"/unitLength[@type='long']" +
"/unit[@type='" + type + "']",
"unitPattern[@count='" + count.attributeValue() + "']");
if (formatPattern.isPresent()) {
String patternStr = formatPattern.get();
if (!patternStr.isEmpty()) {
MessageFormat format =
new MessageFormat(patternStr, locale);
return format.format(new Object[] { amount });
}
}
} catch (XPathException e) {
throw new RuntimeException(e);
}
throw new IllegalArgumentException(
"Could not find pattern for units " + units +
", amount " + amount + ", locale " + locale.toLanguageTag());
}
public String format(Period period,
Locale locale) {
return format(period, false, locale);
}
public String format(Period period,
boolean includeWeeks,
Locale locale) {
int years = period.getYears();
int months = period.getMonths();
int days = period.getDays();
List<String> parts = new ArrayList<>(4);
if (years != 0) {
BigDecimal value = BigDecimal.valueOf(years);
String yearStr = format(ChronoUnit.YEARS, value, locale);
parts.add(yearStr);
}
if (months != 0) {
BigDecimal value = BigDecimal.valueOf(months);
String monthStr = format(ChronoUnit.MONTHS, value, locale);
parts.add(monthStr);
}
if (includeWeeks) {
int weeks = days / 7;
if (weeks != 0) {
days %= 7;
BigDecimal value = BigDecimal.valueOf(weeks);
String weekStr = format(ChronoUnit.WEEKS, value, locale);
parts.add(weekStr);
}
}
if (days != 0) {
BigDecimal value = BigDecimal.valueOf(days);
String dayStr = format(ChronoUnit.DAYS, value, locale);
parts.add(dayStr);
}
return formatList(parts, locale);
}
private String formatList(List<?> parts,
Locale locale) {
if (parts.isEmpty()) {
return "";
}
int size = parts.size();
if (size == 1) {
return String.valueOf(parts.get(0));
}
Map<String, String> patternsByType = new HashMap<>();
for (Path dataFile : dataFilesFor(locale)) {
Object source = createSource(dataFile);
Optional<Element> listPatterns =
locateElement(source, "/ldml/listPatterns");
if (!listPatterns.isPresent()) {
continue;
}
Optional<Element> listPattern =
locateElement(listPatterns.get(), "listPattern[@type='unit']");
if (!listPattern.isPresent()) {
continue;
}
NodeList partList =
listPattern.get().getElementsByTagName("listPatternPart");
int count = partList.getLength();
for (int i = 0; i < count; i++) {
Element part = (Element) partList.item(i);
String type = part.getAttribute("type");
String pattern = part.getTextContent();
patternsByType.putIfAbsent(type, pattern);
}
}
if (size == 2 || size == 3) {
String pattern = patternsByType.get(String.valueOf(size));
if (pattern != null) {
MessageFormat format =new MessageFormat(pattern, locale);
return format.format(parts.toArray());
}
}
MessageFormat startFormat = new MessageFormat(
patternsByType.get("start"), locale);
MessageFormat middleFormat = new MessageFormat(
patternsByType.get("middle"), locale);
MessageFormat endFormat = new MessageFormat(
patternsByType.get("end"), locale);
String text = endFormat.format(
new Object[] { parts.get(size - 2), parts.get(size - 1) });
int index = size - 2;
while (--index > 0) {
text = middleFormat.format(new Object[] { parts.get(index), text });
}
text = startFormat.format(new Object[] { parts.get(index), text });
return text;
}
public static void main(String[] args) {
String localeStr = System.getProperty("locale");
Locale locale = localeStr != null ?
Locale.forLanguageTag(localeStr) : Locale.getDefault();
boolean showWeeks = Boolean.getBoolean("weeks");
PeriodFormatter formatter = new PeriodFormatter();
for (String arg : args) {
Period period = Period.parse(arg);
String s = formatter.format(period, showWeeks, locale);
System.out.println(arg + " => " + s);
}
}
}