diff --git a/.gitignore b/.gitignore index 557acc47..61fdedda 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ # Specific files pkgs/intl/test/number_format_compact_google3_icu_test.dart pkgs/intl/update_from_cldr_data.sh + +.vscode diff --git a/pkgs/intl4x/CHANGELOG.md b/pkgs/intl4x/CHANGELOG.md index aeb25aa9..edfc794d 100644 --- a/pkgs/intl4x/CHANGELOG.md +++ b/pkgs/intl4x/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.8.2-wip + +- Add ICU4X support for number formatting. + ## 0.8.1 - Add ICU4X support for collation. diff --git a/pkgs/intl4x/README.md b/pkgs/intl4x/README.md index 79aee5ca..d5646854 100644 --- a/pkgs/intl4x/README.md +++ b/pkgs/intl4x/README.md @@ -18,7 +18,7 @@ via our [issue tracker](https://github.com/dart-lang/i18n/issues)). | | Number format | List format | Date format | Collation | Display names | Plural Rules | |---|:---:|:---:|:---:|:---:|:---:|:---:| | **ECMA402 (web)** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | -| **ICU4X (web/native)** | | | | | | | +| **ICU4X (web/native)** | :heavy_check_mark: | | | :heavy_check_mark: | | | ## Implementation and Goals diff --git a/pkgs/intl4x/analysis_options.yaml b/pkgs/intl4x/analysis_options.yaml index 354473b4..f0ad1ab9 100644 --- a/pkgs/intl4x/analysis_options.yaml +++ b/pkgs/intl4x/analysis_options.yaml @@ -12,3 +12,6 @@ analyzer: exclude: - "submodules/*" - "lib/src/bindings/*" + + enable-experiment: + - native-assets diff --git a/pkgs/intl4x/lib/src/collation/collation_stub_4x.dart b/pkgs/intl4x/lib/src/collation/collation_stub_4x.dart index 3fec53d7..c4e6a833 100644 --- a/pkgs/intl4x/lib/src/collation/collation_stub_4x.dart +++ b/pkgs/intl4x/lib/src/collation/collation_stub_4x.dart @@ -10,4 +10,4 @@ import 'collation_impl.dart'; /// Stub for the conditional import CollationImpl getCollator4X( Locale locale, Data data, CollationOptions options) => - throw UnimplementedError('Cannot use ECMA outside of web environments.'); + throw UnimplementedError('Cannot use ICU4X in web environments.'); diff --git a/pkgs/intl4x/lib/src/number_format/number_format_4x.dart b/pkgs/intl4x/lib/src/number_format/number_format_4x.dart index f42863e6..b9c4a1a0 100644 --- a/pkgs/intl4x/lib/src/number_format/number_format_4x.dart +++ b/pkgs/intl4x/lib/src/number_format/number_format_4x.dart @@ -2,8 +2,13 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'dart:math'; + +import '../bindings/lib.g.dart' as icu; import '../data.dart'; +import '../data_4x.dart'; import '../locale/locale.dart'; +import '../locale/locale_4x.dart'; import 'number_format_impl.dart'; import 'number_format_options.dart'; @@ -12,10 +17,114 @@ NumberFormatImpl getNumberFormatter4X( NumberFormat4X(locale, data, options); class NumberFormat4X extends NumberFormatImpl { - NumberFormat4X(super.locale, Data data, super.options); + final icu.FixedDecimalFormatter _formatter; + NumberFormat4X(super.locale, Data data, super.options) + : _formatter = icu.FixedDecimalFormatter.withGroupingStrategy( + data.to4X(), + locale.to4X(), + options.groupingStrategy4X(), + ); @override String formatImpl(Object number) { - throw UnimplementedError('Insert diplomat bindings here'); + final fixedDecimal = _toFixedDecimal(number); + final format = _formatter.format(fixedDecimal); + return format; + } + + icu.FixedDecimal _toFixedDecimal(Object number) { + final icu.FixedDecimal fixedDecimal; + fixedDecimal = switch (number) { + final int i => icu.FixedDecimal.fromInt(i), + final double d => icu.FixedDecimal.fromDoubleWithDoublePrecision(d), + final String s => icu.FixedDecimal.fromString(s), + Object() => icu.FixedDecimal.fromString(number.toString()), + }; + return _constructDouble(fixedDecimal); + } + + icu.FixedDecimal _constructDouble(icu.FixedDecimal fixedDecimal) { + fixedDecimal.padStart(options.minimumIntegerDigits); + final minFractionDigits = options.digits?.fractionDigits.$1; + final maxFractionDigits = options.digits?.fractionDigits.$2; + final minSignificantDigits = options.digits?.significantDigits.$1; + final maxSignificantDigits = options.digits?.significantDigits.$2; + + final overhead = fixedDecimal.length - (maxSignificantDigits ?? 0); + final maxSignificantPosition = fixedDecimal.magnitudeStart + overhead; + final maxFractionPosition = + max(fixedDecimal.magnitudeStart, -(maxFractionDigits ?? 0)); + + final roundingPriority = options.digits?.roundingPriority; + final bool? useSignificant; + if (maxFractionDigits != null && + maxSignificantDigits != null && + roundingPriority != null) { + useSignificant = switch (roundingPriority) { + RoundingPriority.auto => true, + RoundingPriority.morePrecision => + maxSignificantPosition < maxFractionPosition, + RoundingPriority.lessPrecision => + maxSignificantPosition > maxFractionPosition, + }; + } else { + useSignificant = null; + } + + if (minFractionDigits != null) { + fixedDecimal.padEnd(-minFractionDigits); + } + if (maxFractionDigits != null && !(useSignificant ?? false)) { + final int position; + if (minFractionDigits != null) { + position = min(maxFractionPosition, -minFractionDigits); + } else { + position = maxFractionPosition; + } + _roundDecimal(fixedDecimal, position); + } + if (minSignificantDigits != null && + fixedDecimal.length < minSignificantDigits) { + final missingZeroes = minSignificantDigits - fixedDecimal.length; + fixedDecimal.padEnd(fixedDecimal.magnitudeStart - missingZeroes); + } + if (maxSignificantDigits != null && + fixedDecimal.length > maxSignificantDigits && + (useSignificant ?? true)) { + _roundDecimal(fixedDecimal, maxSignificantPosition); + } + return fixedDecimal; + } + + void _roundDecimal( + icu.FixedDecimal fixedDecimal, int maxSignificantPosition) { + final roundingFunction = switch (options.roundingMode) { + RoundingMode.ceil => fixedDecimal.ceil, + RoundingMode.floor => fixedDecimal.floor, + RoundingMode.expand => fixedDecimal.expand, + RoundingMode.trunc => fixedDecimal.trunc, + RoundingMode.halfCeil => fixedDecimal.halfCeil, + RoundingMode.halfFloor => fixedDecimal.halfFloor, + RoundingMode.halfExpand => fixedDecimal.halfExpand, + RoundingMode.halfTrunc => fixedDecimal.halfTrunc, + RoundingMode.halfEven => fixedDecimal.halfEven, + }; + roundingFunction(maxSignificantPosition); } } + +extension on NumberFormatOptions { + icu.FixedDecimalGroupingStrategy groupingStrategy4X() => + switch (useGrouping) { + Grouping.always => icu.FixedDecimalGroupingStrategy.always, + Grouping.auto => icu.FixedDecimalGroupingStrategy.auto, + Grouping.never => icu.FixedDecimalGroupingStrategy.never, + Grouping.min2 => icu.FixedDecimalGroupingStrategy.min2, + }; +} + +extension on icu.FixedDecimal { + int get length => fractionLength + integerLength; + int get fractionLength => max(0, -magnitudeStart); + int get integerLength => max(0, magnitudeEnd + 1); +} diff --git a/pkgs/intl4x/lib/src/number_format/number_format_impl.dart b/pkgs/intl4x/lib/src/number_format/number_format_impl.dart index 3ed53876..c33f7c61 100644 --- a/pkgs/intl4x/lib/src/number_format/number_format_impl.dart +++ b/pkgs/intl4x/lib/src/number_format/number_format_impl.dart @@ -7,9 +7,10 @@ import '../ecma/ecma_policy.dart'; import '../locale/locale.dart'; import '../options.dart'; import '../utils.dart'; -import 'number_format_4x.dart'; import 'number_format_options.dart'; import 'number_format_stub.dart' if (dart.library.js) 'number_format_ecma.dart'; +import 'number_format_stub_4x.dart' + if (dart.library.io) 'number_format_4x.dart'; /// This is an intermediate to defer to the actual implementations of /// Number formatting. diff --git a/pkgs/intl4x/lib/src/number_format/number_format_options.dart b/pkgs/intl4x/lib/src/number_format/number_format_options.dart index aa89913c..4e53ea3c 100644 --- a/pkgs/intl4x/lib/src/number_format/number_format_options.dart +++ b/pkgs/intl4x/lib/src/number_format/number_format_options.dart @@ -274,7 +274,8 @@ final class Digits { significantDigits = (null, null), roundingPriority = null, assert(roundingIncrement == null || - ((minimum != null || maximum != null) || minimum == maximum)); + ((minimum != null || maximum != null) || minimum == maximum)), + assert((minimum == null || maximum == null) || minimum < maximum); const Digits.withSignificantDigits({ int? minimum = 1, diff --git a/pkgs/intl4x/lib/src/number_format/number_format_stub_4x.dart b/pkgs/intl4x/lib/src/number_format/number_format_stub_4x.dart new file mode 100644 index 00000000..90750df8 --- /dev/null +++ b/pkgs/intl4x/lib/src/number_format/number_format_stub_4x.dart @@ -0,0 +1,12 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../data.dart'; +import '../locale/locale.dart'; +import 'number_format_impl.dart'; +import 'number_format_options.dart'; + +NumberFormatImpl getNumberFormatter4X( + Locale locale, Data data, NumberFormatOptions options) => + throw UnimplementedError('Cannot use ICU4X in web environments.'); diff --git a/pkgs/intl4x/lib/src/plural_rules/plural_rules_impl.dart b/pkgs/intl4x/lib/src/plural_rules/plural_rules_impl.dart index 41d1121d..41125a6c 100644 --- a/pkgs/intl4x/lib/src/plural_rules/plural_rules_impl.dart +++ b/pkgs/intl4x/lib/src/plural_rules/plural_rules_impl.dart @@ -9,9 +9,9 @@ import '../locale/locale.dart'; import '../options.dart'; import '../utils.dart'; import 'plural_rules.dart'; -import 'plural_rules_4x.dart'; import 'plural_rules_options.dart'; import 'plural_rules_stub.dart' if (dart.library.js) 'plural_rules_ecma.dart'; +import 'plural_rules_stub_4x.dart' if (dart.library.io) 'plural_rules_4x.dart'; abstract class PluralRulesImpl { final Locale locale; diff --git a/pkgs/intl4x/lib/src/plural_rules/plural_rules_stub_4x.dart b/pkgs/intl4x/lib/src/plural_rules/plural_rules_stub_4x.dart new file mode 100644 index 00000000..43d69f30 --- /dev/null +++ b/pkgs/intl4x/lib/src/plural_rules/plural_rules_stub_4x.dart @@ -0,0 +1,15 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../../plural_rules.dart'; +import '../data.dart'; +import '../locale/locale.dart'; +import 'plural_rules_impl.dart'; + +PluralRulesImpl getPluralSelect4X( + Locale locale, + Data data, + PluralRulesOptions options, +) => + throw UnimplementedError('Cannot use ICU4X in web environments.'); diff --git a/pkgs/intl4x/pubspec.yaml b/pkgs/intl4x/pubspec.yaml index 6f69bff2..5d6d321a 100644 --- a/pkgs/intl4x/pubspec.yaml +++ b/pkgs/intl4x/pubspec.yaml @@ -1,7 +1,7 @@ name: intl4x description: >- A lightweight modular library for internationalization (i18n) functionality. -version: 0.8.1 +version: 0.8.2-wip repository: https://github.com/dart-lang/i18n/tree/main/pkgs/intl4x platforms: ## TODO: Add native platforms once ICU4X is integrated. web: @@ -18,6 +18,7 @@ dev_dependencies: args: ^2.4.2 build_runner: ^2.1.4 build_web_compilers: ^3.2.1 + collection: ^1.18.0 dart_flutter_team_lints: ^1.0.0 lints: ^2.0.0 native_assets_cli: ^0.3.2 diff --git a/pkgs/intl4x/test/numberformat_test.dart b/pkgs/intl4x/test/numberformat_test.dart new file mode 100644 index 00000000..dc853b65 --- /dev/null +++ b/pkgs/intl4x/test/numberformat_test.dart @@ -0,0 +1,172 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:collection/collection.dart'; +import 'package:intl4x/intl4x.dart'; +import 'package:intl4x/number_format.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +void main() { + final intl = Intl(locale: const Locale(language: 'en', region: 'US')); + group('grouping', () { + testWithFormatting('always', () { + final numberFormatOptions = intl.numberFormat( + NumberFormatOptions.custom(useGrouping: Grouping.always)); + expect(numberFormatOptions.format(1000), '1,000'); + expect(numberFormatOptions.format(10000), '10,000'); + }); + + testWithFormatting('never', () { + final numberFormatOptions = intl.numberFormat( + NumberFormatOptions.custom(useGrouping: Grouping.never)); + expect(numberFormatOptions.format(1000), '1000'); + expect(numberFormatOptions.format(10000), '10000'); + }); + + testWithFormatting('auto', () { + final numberFormatOptions = intl + .numberFormat(NumberFormatOptions.custom(useGrouping: Grouping.auto)); + expect(numberFormatOptions.format(1000), '1,000'); + expect(numberFormatOptions.format(10000), '10,000'); + }); + + testWithFormatting('min2', () { + final numberFormatOptions = intl + .numberFormat(NumberFormatOptions.custom(useGrouping: Grouping.min2)); + expect(numberFormatOptions.format(1000), '1000'); + expect(numberFormatOptions.format(10000), '10,000'); + }); + }); + + group('digits', () { + testWithFormatting('fractionDigits', () { + String formatter(Object number) => intl + .numberFormat(NumberFormatOptions.custom( + minimumIntegerDigits: 5, + useGrouping: Grouping.never, + )) + .format(number); + expect(formatter(540), '00540'); + }); + + testWithFormatting('fractionDigits', () { + String formatter(Object number) => intl + .numberFormat(NumberFormatOptions.custom( + digits: const Digits.withSignificantDigits(maximum: 1), + useGrouping: Grouping.never, + )) + .format(number); + expect(formatter(540), '500'); + }); + + testWithFormatting('significantDigits', () { + final numberFormatOptions = intl.numberFormat(NumberFormatOptions.custom( + digits: const Digits.withSignificantDigits(minimum: 1, maximum: 3), + )); + + expect(numberFormatOptions.format(3), '3'); + expect(numberFormatOptions.format(3.1), '3.1'); + expect(numberFormatOptions.format(3.12), '3.12'); + expect(numberFormatOptions.format(3.123), '3.12'); + }); + + testWithFormatting('fractionDigits min', () { + String formatter(Object number) => intl + .numberFormat(NumberFormatOptions.custom( + minimumIntegerDigits: 3, + digits: const Digits.withFractionDigits(minimum: 4), + )) + .format(number); + expect(formatter(4.33), '004.3300'); + }); + + testWithFormatting('fractionDigits max', () { + String formatter(Object number) => intl + .numberFormat(NumberFormatOptions.custom( + minimumIntegerDigits: 3, + digits: const Digits.withFractionDigits( + maximum: 1, + ), + )) + .format(number); + expect(formatter(4.33), '004.3'); + }); + + testWithFormatting('fractionDigits min < max', () { + String formatter(Object number) => intl + .numberFormat(NumberFormatOptions.custom( + minimumIntegerDigits: 3, + digits: const Digits.withFractionDigits( + minimum: 4, + maximum: 6, + ), + )) + .format(number); + expect(formatter(4.33), '004.3300'); + }); + }); + + testWithFormatting('RoundingMode', () { + for (final roundingMode in RoundingMode.values) { + final expectation = switch (roundingMode) { + RoundingMode.ceil => [2.3, 2.3, 2.3, -2.2, -2.2, -2.2], + RoundingMode.floor => [2.2, 2.2, 2.2, -2.3, -2.3, -2.3], + RoundingMode.expand => [2.3, 2.3, 2.3, -2.3, -2.3, -2.3], + RoundingMode.trunc => [2.2, 2.2, 2.2, -2.2, -2.2, -2.2], + RoundingMode.halfCeil => [2.2, 2.3, 2.3, -2.2, -2.2, -2.3], + RoundingMode.halfFloor => [2.2, 2.2, 2.3, -2.2, -2.3, -2.3], + RoundingMode.halfExpand => [2.2, 2.3, 2.3, -2.2, -2.3, -2.3], + RoundingMode.halfTrunc => [2.2, 2.2, 2.3, -2.2, -2.2, -2.3], + RoundingMode.halfEven => [2.2, 2.2, 2.3, -2.2, -2.2, -2.3], + } + .map((e) => e.toString()) + .toList(); + String formatter(Object number) => intl + .numberFormat(NumberFormatOptions.custom( + roundingMode: roundingMode, + digits: const Digits.withSignificantDigits(maximum: 2))) + .format(number); + final inputs = [2.23, 2.25, 2.28, -2.23, -2.25, -2.28]; + for (final pairs in IterableZip([inputs, expectation])) { + final input = pairs[0]; + final expectiation = pairs[1]; + expect( + formatter(input), + expectiation, + reason: 'In rounding mode ${roundingMode.name}', + ); + } + } + }); + group('RoundingPriority', () { + String formatter(Object number, Digits digits) => intl + .numberFormat(NumberFormatOptions.custom(digits: digits)) + .format(number); + testWithFormatting('lessPrecision', () { + expect( + formatter( + 1.23456, + const Digits.withSignificantAndFractionDigits( + roundingPriority: RoundingPriority.lessPrecision, + maximumSignificantDigits: 3, + maximumFractionDigits: 3, + )), + '1.23'); + }); + + testWithFormatting('morePrecision', () { + expect( + formatter( + 1.23456, + const Digits.withSignificantAndFractionDigits( + roundingPriority: RoundingPriority.morePrecision, + maximumSignificantDigits: 3, + maximumFractionDigits: 3, + )), + '1.235'); + }); + }); +}