From 2eefaf4024eb1515c11f6e210733fbaa3d899e12 Mon Sep 17 00:00:00 2001 From: melloware Date: Wed, 1 Apr 2026 15:31:22 -0400 Subject: [PATCH] Spec #2124: whitespace normalization in DateTime/Number Converter --- .../faces/convert/DateTimeConverter.java | 132 ++++++++++++++---- .../faces/convert/NumberConverter.java | 124 ++++++++++------ .../faces/convert/DateTimeConverterTest.java | 31 ++++ .../faces/convert/NumberConverterTest.java | 25 +++- .../apache/myfaces/util/MessageUtilsTest.java | 4 +- .../facelets/tag/faces/core/CoreTestCase.java | 34 ++++- 6 files changed, 263 insertions(+), 87 deletions(-) diff --git a/api/src/main/java/jakarta/faces/convert/DateTimeConverter.java b/api/src/main/java/jakarta/faces/convert/DateTimeConverter.java index 01201cf5a5..c73326dfd2 100755 --- a/api/src/main/java/jakarta/faces/convert/DateTimeConverter.java +++ b/api/src/main/java/jakarta/faces/convert/DateTimeConverter.java @@ -27,13 +27,18 @@ import java.time.OffsetDateTime; import java.time.OffsetTime; import java.time.ZonedDateTime; +import java.time.chrono.IsoChronology; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.FormatStyle; +import java.time.format.ResolverStyle; +import java.time.temporal.ChronoField; import java.time.temporal.TemporalAccessor; import java.time.temporal.TemporalQuery; import java.util.Date; import java.util.Locale; import java.util.TimeZone; +import java.util.regex.Pattern; import jakarta.faces.component.PartialStateHolder; import jakarta.faces.component.UIComponent; @@ -86,6 +91,12 @@ public class DateTimeConverter private static final String STYLE_FULL = "full"; private static final TimeZone TIMEZONE_DEFAULT = TimeZone.getTimeZone("GMT"); + private static final Pattern ESCAPED_DATE_TIME_PATTERN = Pattern.compile("'[^']*+'"); + private static final Pattern FIXED_WIDTH_WHITESPACE = + Pattern.compile("[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]"); + private static final Pattern ZERO_WIDTH_WHITESPACE = + Pattern.compile("[\u200b-\u200d\u2060\ufeff]"); + private String _dateStyle; private Locale _locale; private String _pattern; @@ -113,17 +124,18 @@ public Object getAsObject(FacesContext facesContext, UIComponent uiComponent, St { if (isJava8DateTimeFormatter()) { - DateTimeFormatter format = getDateTimeFormatter(); + DateTimeFormatter format = getDateTimeFormatter(true); + String toParse = normalizeWhitespace(value); try { TemporalQuery tq = getTemporalQuery(); if (tq != null) { - return format.parse(value, tq); + return format.parse(toParse, tq); } else { - return format.parse(value); + return format.parse(toParse); } } catch (Exception e) @@ -143,7 +155,7 @@ else if (TYPE_OFFSET_TIME.equals(type) || TYPE_OFFSET_DATE_TIME.equals(type)) { currentDate = ZonedDateTime.now(); } - Object[] args = new Object[]{value, + Object[] args = new Object[]{toParse, format.format(currentDate),MessageUtils.getLabel(facesContext, uiComponent)}; if(type.equals(TYPE_LOCAL_DATE)) @@ -225,7 +237,7 @@ public String getAsString(FacesContext facesContext, UIComponent uiComponent, Ob if (isJava8DateTimeFormatter()) { - DateTimeFormatter format = getDateTimeFormatter(); + DateTimeFormatter format = getDateTimeFormatter(false); if (value instanceof TemporalAccessor accessor) { @@ -300,14 +312,19 @@ else if (type.equals(TYPE_BOTH)) return format; } - private DateTimeFormatter getDateTimeFormatter() + /** + * @param forParsing {@code true} for {@link #getAsObject}; when {@code localDate}, {@code localDateTime}, or + * {@code localTime} without an explicit pattern, use a whitespace-normalized localized pattern. + */ + private DateTimeFormatter getDateTimeFormatter(boolean forParsing) { - DateTimeFormatter formatter = null; + DateTimeFormatter formatter; String type = getType(); String pattern = getPattern(); + Locale locale = getLocale(); + if (pattern != null && pattern.length() > 0) { - Locale locale = getLocale(); if (locale == null) { formatter = DateTimeFormatter.ofPattern(pattern); @@ -316,52 +333,105 @@ private DateTimeFormatter getDateTimeFormatter() { formatter = DateTimeFormatter.ofPattern(pattern, locale); } + return formatter; } - else + + if (forParsing + && (TYPE_LOCAL_DATE.equals(type) || TYPE_LOCAL_DATE_TIME.equals(type) || TYPE_LOCAL_TIME.equals(type))) { if (TYPE_LOCAL_DATE.equals(type)) { - formatter = DateTimeFormatter.ofLocalizedDate(calcFormatStyle(getDateStyle())); + formatter = createLocalizedParseFormatter(calcFormatStyle(getDateStyle()), null, locale); } - else if (TYPE_LOCAL_DATE_TIME.equals(type) ) + else if (TYPE_LOCAL_DATE_TIME.equals(type)) { String timeStyle = getTimeStyle(); if (timeStyle != null && timeStyle.length() > 0) { - formatter = DateTimeFormatter.ofLocalizedDateTime( - calcFormatStyle(getDateStyle()), calcFormatStyle(timeStyle)); + formatter = createLocalizedParseFormatter( + calcFormatStyle(getDateStyle()), calcFormatStyle(timeStyle), locale); } else { - formatter = DateTimeFormatter.ofLocalizedDateTime( - calcFormatStyle(getDateStyle())); + FormatStyle ds = calcFormatStyle(getDateStyle()); + formatter = createLocalizedParseFormatter(ds, ds, locale); } } - else if (TYPE_LOCAL_TIME.equals(type) ) - { - formatter = DateTimeFormatter.ofLocalizedTime(calcFormatStyle(getTimeStyle())); - } - else if (TYPE_OFFSET_TIME.equals(type)) - { - formatter = DateTimeFormatter.ISO_OFFSET_TIME; - } - else if (TYPE_OFFSET_DATE_TIME.equals(type)) + else { - formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + formatter = createLocalizedParseFormatter(null, calcFormatStyle(getTimeStyle()), locale); } - else if (TYPE_ZONED_DATE_TIME.equals(type)) + return formatter; + } + + if (TYPE_LOCAL_DATE.equals(type)) + { + formatter = DateTimeFormatter.ofLocalizedDate(calcFormatStyle(getDateStyle())); + } + else if (TYPE_LOCAL_DATE_TIME.equals(type)) + { + String timeStyle = getTimeStyle(); + if (timeStyle != null && timeStyle.length() > 0) { - formatter = DateTimeFormatter.ISO_ZONED_DATE_TIME; + formatter = DateTimeFormatter.ofLocalizedDateTime( + calcFormatStyle(getDateStyle()), calcFormatStyle(timeStyle)); } - - Locale locale = getLocale(); - if (locale != null) + else { - formatter = formatter.withLocale(locale); + formatter = DateTimeFormatter.ofLocalizedDateTime( + calcFormatStyle(getDateStyle())); } } + else if (TYPE_LOCAL_TIME.equals(type)) + { + formatter = DateTimeFormatter.ofLocalizedTime(calcFormatStyle(getTimeStyle())); + } + else if (TYPE_OFFSET_TIME.equals(type)) + { + formatter = DateTimeFormatter.ISO_OFFSET_TIME; + } + else if (TYPE_OFFSET_DATE_TIME.equals(type)) + { + formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + } + else if (TYPE_ZONED_DATE_TIME.equals(type)) + { + formatter = DateTimeFormatter.ISO_ZONED_DATE_TIME; + } + else + { + throw new ConverterException("invalid type '" + _type + '\''); + } + + if (locale != null) + { + formatter = formatter.withLocale(locale); + } return formatter; } + + private static DateTimeFormatter createLocalizedParseFormatter( + FormatStyle dateStyle, FormatStyle timeStyle, Locale locale) + { + Locale loc = locale != null ? locale : Locale.getDefault(); + String localizedPattern = DateTimeFormatterBuilder.getLocalizedDateTimePattern( + dateStyle, timeStyle, IsoChronology.INSTANCE, loc); + String normalizedPattern = normalizeWhitespace(localizedPattern); + DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder().appendPattern(normalizedPattern); + if (!ESCAPED_DATE_TIME_PATTERN.matcher(normalizedPattern).replaceAll("").contains("uu")) + { + builder.parseDefaulting(ChronoField.ERA, 1); + } + return builder.toFormatter(loc) + .withChronology(IsoChronology.INSTANCE) + .withResolverStyle(ResolverStyle.STRICT); + } + + private static String normalizeWhitespace(CharSequence text) + { + String normalized = FIXED_WIDTH_WHITESPACE.matcher(text).replaceAll(" "); + return ZERO_WIDTH_WHITESPACE.matcher(normalized).replaceAll(""); + } /** * According to java8 api, parse() also receives a TemporalQuery parameter that works as a qualifier to decide diff --git a/api/src/main/java/jakarta/faces/convert/NumberConverter.java b/api/src/main/java/jakarta/faces/convert/NumberConverter.java index 5661d7b9c0..56da96b370 100755 --- a/api/src/main/java/jakarta/faces/convert/NumberConverter.java +++ b/api/src/main/java/jakarta/faces/convert/NumberConverter.java @@ -27,6 +27,7 @@ import java.text.ParsePosition; import java.util.Currency; import java.util.Locale; +import java.util.regex.Pattern; import jakarta.el.ValueExpression; import jakarta.faces.component.PartialStateHolder; @@ -61,6 +62,11 @@ public class NumberConverter implements Converter, PartialStateHolder public static final String PATTERN_ID = "jakarta.faces.converter.NumberConverter.PATTERN"; public static final String PERCENT_ID = "jakarta.faces.converter.NumberConverter.PERCENT"; + private static final Pattern FIXED_WIDTH_WHITESPACE = + Pattern.compile("[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]"); + private static final Pattern ZERO_WIDTH_WHITESPACE = + Pattern.compile("[\u200b-\u200d\u2060\ufeff]"); + private String _currencyCode; private String _currencySymbol; private Locale _locale; @@ -93,7 +99,7 @@ public Number getAsObject(FacesContext facesContext, UIComponent uiComponent, St { return null; } - + NumberFormat format = getNumberFormat(facesContext); format.setParseIntegerOnly(_integerOnly); @@ -116,65 +122,87 @@ public Number getAsObject(FacesContext facesContext, UIComponent uiComponent, St } } - DecimalFormatSymbols dfs = df.getDecimalFormatSymbols(); - boolean changed = false; - if(dfs.getGroupingSeparator() == '\u00a0') - { - dfs.setGroupingSeparator(' '); - df.setDecimalFormatSymbols(dfs); - value = value.replace('\u00a0', ' '); - changed = true; - } - formatCurrency(format); try { + DecimalFormatSymbols symbols = df.getDecimalFormatSymbols(); + char origGroupingSep = symbols.getGroupingSeparator(); + String origPrefix = df.getPositivePrefix(); + String origSuffix = df.getPositiveSuffix(); + String origNegPrefix = df.getNegativePrefix(); + String origNegSuffix = df.getNegativeSuffix(); + + boolean hasFixedWidthWhitespace = + FIXED_WIDTH_WHITESPACE.matcher(String.valueOf(origGroupingSep)).matches() + || FIXED_WIDTH_WHITESPACE.matcher(origPrefix).find() + || FIXED_WIDTH_WHITESPACE.matcher(origSuffix).find() + || FIXED_WIDTH_WHITESPACE.matcher(origNegPrefix).find() + || FIXED_WIDTH_WHITESPACE.matcher(origNegSuffix).find(); + + if (hasFixedWidthWhitespace) + { + String normalizedValue = normalizeWhitespace(value); + + if (FIXED_WIDTH_WHITESPACE.matcher(String.valueOf(origGroupingSep)).matches()) + { + symbols.setGroupingSeparator(' '); + } + + df.setDecimalFormatSymbols(symbols); + df.setPositivePrefix(normalizeWhitespace(origPrefix)); + df.setPositiveSuffix(normalizeWhitespace(origSuffix)); + df.setNegativePrefix(normalizeWhitespace(origNegPrefix)); + df.setNegativeSuffix(normalizeWhitespace(origNegSuffix)); + + try + { + return parse(normalizedValue, format, destType); + } + catch (ParseException pe) + { + symbols.setGroupingSeparator(origGroupingSep); + df.setDecimalFormatSymbols(symbols); + df.setPositivePrefix(origPrefix); + df.setPositiveSuffix(origSuffix); + df.setNegativePrefix(origNegPrefix); + df.setNegativeSuffix(origNegSuffix); + } + } + return parse(value, format, destType); } catch (ParseException e) { - if(changed) + if (getPattern() != null) { - dfs.setGroupingSeparator('\u00a0'); - df.setDecimalFormatSymbols(dfs); + throw new ConverterException(MessageUtils.getErrorMessage(facesContext, + PATTERN_ID, + new Object[]{value, "$###,###", MessageUtils.getLabel(facesContext, uiComponent)})); } - try + else if (getType().equals("number")) { - return parse(value, format, destType); + throw new ConverterException(MessageUtils.getErrorMessage(facesContext, + NUMBER_ID, + new Object[]{value, format.format(21), + MessageUtils.getLabel(facesContext, uiComponent)})); } - catch (ParseException pe) + else if (getType().equals("currency")) { - if (getPattern() != null) - { - throw new ConverterException(MessageUtils.getErrorMessage(facesContext, - PATTERN_ID, - new Object[]{value, "$###,###", MessageUtils.getLabel(facesContext, uiComponent)})); - } - else if (getType().equals("number")) - { - throw new ConverterException(MessageUtils.getErrorMessage(facesContext, - NUMBER_ID, - new Object[]{value, format.format(21), - MessageUtils.getLabel(facesContext, uiComponent)})); - } - else if (getType().equals("currency")) - { - throw new ConverterException(MessageUtils.getErrorMessage(facesContext, - CURRENCY_ID, - new Object[]{value, format.format(42.25), - MessageUtils.getLabel(facesContext, uiComponent)})); - } - else if (getType().equals("percent")) - { - throw new ConverterException(MessageUtils.getErrorMessage(facesContext, - PERCENT_ID, - new Object[]{value, format.format(.90), - MessageUtils.getLabel(facesContext, uiComponent)})); - } + throw new ConverterException(MessageUtils.getErrorMessage(facesContext, + CURRENCY_ID, + new Object[]{value, format.format(42.25), + MessageUtils.getLabel(facesContext, uiComponent)})); + } + else if (getType().equals("percent")) + { + throw new ConverterException(MessageUtils.getErrorMessage(facesContext, + PERCENT_ID, + new Object[]{value, format.format(.90), + MessageUtils.getLabel(facesContext, uiComponent)})); } } - + return null; } @@ -579,6 +607,12 @@ private DecimalFormatSymbols getDecimalFormatSymbols() { return new DecimalFormatSymbols(getLocale()); } + + private static String normalizeWhitespace(String text) + { + String normalized = FIXED_WIDTH_WHITESPACE.matcher(text).replaceAll(" "); + return ZERO_WIDTH_WHITESPACE.matcher(normalized).replaceAll(""); + } private boolean _initialStateMarked = false; diff --git a/impl/src/test/java/jakarta/faces/convert/DateTimeConverterTest.java b/impl/src/test/java/jakarta/faces/convert/DateTimeConverterTest.java index 02b0dd01c3..86f8ce7b40 100644 --- a/impl/src/test/java/jakarta/faces/convert/DateTimeConverterTest.java +++ b/impl/src/test/java/jakarta/faces/convert/DateTimeConverterTest.java @@ -20,6 +20,8 @@ package jakarta.faces.convert; import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.LocalTime; import java.util.Date; import java.util.Locale; import java.util.TimeZone; @@ -105,4 +107,33 @@ public void testGetAsObject() Assertions.assertTrue(false, "this date should not be parsable - and it is, so this is wrong."); } } + + @Test + public void testLocalDateParsesRegularSpacesWhenLocaleUsesFixedWidthWhitespace() + { + UIInput input = new UIInput(); + mock.setType("localDate"); + mock.setLocale(Locale.FRANCE); + mock.setDateStyle("short"); + LocalDate expected = LocalDate.of(2024, 6, 15); + String formatted = mock.getAsString(facesContext, input, expected); + String withAsciiSpaces = formatted.replace('\u202f', ' ').replace('\u00a0', ' '); + Object parsed = mock.getAsObject(facesContext, input, withAsciiSpaces); + Assertions.assertEquals(expected, parsed); + } + + @Test + public void testLocalTimeStripsZeroWidthWhitespaceFromInput() + { + UIInput input = new UIInput(); + mock.setType("localTime"); + mock.setLocale(Locale.GERMANY); + mock.setTimeStyle("short"); + LocalTime expected = LocalTime.of(14, 30); + String formatted = mock.getAsString(facesContext, input, expected); + Assertions.assertFalse(formatted.isEmpty()); + String withZw = formatted.charAt(0) + "\u200b" + formatted.substring(1); + Object parsed = mock.getAsObject(facesContext, input, withZw); + Assertions.assertEquals(expected, parsed); + } } diff --git a/impl/src/test/java/jakarta/faces/convert/NumberConverterTest.java b/impl/src/test/java/jakarta/faces/convert/NumberConverterTest.java index 403a4bcd81..066f8883ba 100644 --- a/impl/src/test/java/jakarta/faces/convert/NumberConverterTest.java +++ b/impl/src/test/java/jakarta/faces/convert/NumberConverterTest.java @@ -53,11 +53,12 @@ public void tearDown() throws Exception mock = null; } - /* - * temporarily comment out tests that fail, until Matthias Wessendorf has time to investigate + /** + * Requires JDK locale data consistent with the hard-coded input (historically {@code java.locale.providers=COMPAT}; + * COMPAT is unavailable on newer JDKs). */ @Test - @Disabled // java.locale.providers=COMPAT not working currently + @Disabled("French currency input vs JDK locale data; enable when expectations match the runtime") public void testFranceLocaleWithNonBreakingSpace() { mock.setLocale(Locale.FRANCE); @@ -68,9 +69,9 @@ public void testFranceLocaleWithNonBreakingSpace() Number number = (Number) mock.getAsObject(FacesContext.getCurrentInstance(), input, "12\u00a0345,68 \u20AC"); Assertions.assertNotNull(number); } - + @Test - @Disabled // java.locale.providers=COMPAT not working currently + @Disabled("French currency input vs JDK locale data; enable when expectations match the runtime") public void testFranceLocaleWithoutNonBreakingSpace() { mock.setLocale(Locale.FRANCE); @@ -228,6 +229,20 @@ public void testGetAsObjectWithBigIntegerAndParsePosition() } } + @Test + public void testCzechLocaleNbspGroupingStripsZeroWidthWhitespace() + { + mock.setLocale(new Locale("cs")); + mock.setIntegerOnly(true); + mock.setGroupingUsed(true); + FacesContext.getCurrentInstance().getViewRoot().setLocale(new Locale("cs")); + UIInput input = new UIInput(); + Number number = (Number) mock.getAsObject(FacesContext.getCurrentInstance(), input, + "7\u200b\u00a0000"); + Assertions.assertNotNull(number); + Assertions.assertEquals(7000L, number.longValue()); + } + @Test public void testGetAsObjectParseIntOnly(){ facesContext.getViewRoot().setLocale(Locale.US); diff --git a/impl/src/test/java/org/apache/myfaces/util/MessageUtilsTest.java b/impl/src/test/java/org/apache/myfaces/util/MessageUtilsTest.java index 9b4b03cb20..5469926784 100644 --- a/impl/src/test/java/org/apache/myfaces/util/MessageUtilsTest.java +++ b/impl/src/test/java/org/apache/myfaces/util/MessageUtilsTest.java @@ -15,6 +15,7 @@ */ package org.apache.myfaces.util; +import java.text.NumberFormat; import java.util.Locale; import java.util.ResourceBundle; @@ -195,7 +196,8 @@ public void testGetMessageWithBundleNameLocale() public void testSubstituteParamsWithDELocale() { String paramString = MessageUtils.substituteParams(Locale.GERMANY, "currency {0,number,currency}", new Object[]{100}); - Assertions.assertEquals("currency 100,00 \u20ac",paramString); + String expected = "currency " + NumberFormat.getCurrencyInstance(Locale.GERMANY).format(100); + Assertions.assertEquals(expected, paramString); } /** diff --git a/impl/src/test/java/org/apache/myfaces/view/facelets/tag/faces/core/CoreTestCase.java b/impl/src/test/java/org/apache/myfaces/view/facelets/tag/faces/core/CoreTestCase.java index 5489974ae1..73abcbbe05 100644 --- a/impl/src/test/java/org/apache/myfaces/view/facelets/tag/faces/core/CoreTestCase.java +++ b/impl/src/test/java/org/apache/myfaces/view/facelets/tag/faces/core/CoreTestCase.java @@ -20,6 +20,8 @@ package org.apache.myfaces.view.facelets.tag.faces.core; import org.apache.myfaces.view.facelets.tag.faces.core.reset.ResetValuesBean; +import java.text.DateFormat; +import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; import java.util.Map; @@ -193,15 +195,37 @@ public void testConvertDateTimeHandler() throws Exception Assertions.assertNotNull(out5.getConverter()); DateTimeConverter converter6 = (DateTimeConverter) out6.getConverter(); - Assertions.assertEquals("12/24/69", out1.getConverter().getAsString( + TimeZone gmt = TimeZone.getTimeZone("GMT"); + + DateFormat shortDate = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US); + shortDate.setLenient(false); + shortDate.setTimeZone(gmt); + Assertions.assertEquals(shortDate.format(now), out1.getConverter().getAsString( facesContext, out1, now)); - Assertions.assertEquals("12/24/69 6:57:12 AM", out2.getConverter() + + DateFormat shortDateMediumTime = DateFormat.getDateTimeInstance( + DateFormat.SHORT, DateFormat.MEDIUM, Locale.US); + shortDateMediumTime.setLenient(false); + shortDateMediumTime.setTimeZone(gmt); + Assertions.assertEquals(shortDateMediumTime.format(now), out2.getConverter() .getAsString(facesContext, out2, now)); - Assertions.assertEquals("Dec 24, 1969", out3.getConverter() + + DateFormat mediumDate = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.US); + mediumDate.setTimeZone(TimeZone.getTimeZone("CST")); + mediumDate.setLenient(false); + Assertions.assertEquals(mediumDate.format(now), out3.getConverter() .getAsString(facesContext, out3, now)); - Assertions.assertEquals("6:57:12 AM", out4.getConverter() + + DateFormat mediumTime = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.US); + mediumTime.setLenient(false); + mediumTime.setTimeZone(gmt); + Assertions.assertEquals(mediumTime.format(now), out4.getConverter() .getAsString(facesContext, out4, now)); - Assertions.assertEquals("0:57 AM, CST", out5.getConverter() + + SimpleDateFormat patternCst = new SimpleDateFormat("K:mm a, z", Locale.US); + patternCst.setTimeZone(TimeZone.getTimeZone("CST")); + patternCst.setLenient(false); + Assertions.assertEquals(patternCst.format(now), out5.getConverter() .getAsString(facesContext, out5, now)); Assertions.assertEquals(TimeZone.getTimeZone("GMT"), converter6.getTimeZone()); }