diff --git a/README.md b/README.md index f39884c..173fcef 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,32 @@ Some parts of the PkPass specification are either not yet implemented, or not pl - `nfc`: Card payment information for Apple Pay. - Not planned, feel free to contribute. - `webService`: Information used to update passes using the web service. - Planned, feel free to contribute. +## Localizations + +This package aims to implement PkPass localizations as well as possible. Any localizable value +can be accessed using a `getLocalized...` method, e.g. `myPass.getLocalizedDescription()` taking a `Locale` +as argument. In case the requested locale is not available, the following fallbacks are used: + +- `en` - English, any +- `zh` - Chinese, generic language group +- `hi` - Hindi +- `es` - Spanish +- `fr` - French +- *In case neither available, take just any language you can find. We are likely dealing with a local product then.* + +The used fallback languages are the five mostly understood languages in the world, feel free to propose better or more +precise fallback mechanisms. + +## Barcode encodings + +The PkPass standard is quite vague about the Barcode String encoding used. Technically, all IANA character set names +are allowed. Since this might be some overhead to implement, the following encoders are supported by default: + +- `Latin1Codec` (default according to PkPass spec) - `iso-8859-1`, also fallback onto `iso-8859` and `iso8859` +- `Utf8Codec` (most common one) - `utf-8`, also fallback onto `utf8` + +The supported encoders can be extended by adding a `String` `Encoder` pair to `PassBarcode.supportedCodecs`. + ## Dependencies and compatibility Any package should keep its dependencies as minimal as possible. Sometimes, there are specifications making this diff --git a/example/pkpass_example.dart b/example/pkpass_example.dart index 8596a93..2686969 100644 --- a/example/pkpass_example.dart +++ b/example/pkpass_example.dart @@ -25,6 +25,9 @@ Future main(List args) async { print('First barcode: ${pass.metadata.barcodes.firstOrNull?.message}'); print('Location: ${pass.metadata.locations.firstOrNull?.relevantText}'); print('Date: ${pass.metadata.relevantDate}'); + print( + 'Boarding pass: ${pass.metadata.boardingPass?.headerFields.firstOrNull?.getLocalizedLabel(pass, Locale.fromSubtags(languageCode: 'tlh'))}', + ); return 0; } diff --git a/lib/src/models/pass.dart b/lib/src/models/pass.dart index 3a85025..2d3d995 100644 --- a/lib/src/models/pass.dart +++ b/lib/src/models/pass.dart @@ -1,3 +1,6 @@ +import 'package:intl/locale.dart'; + +import 'package:pkpass/pkpass.dart'; import 'package:pkpass/src/models/barcode.dart'; import 'package:pkpass/src/utils/mabe_decode.dart'; import 'beacon.dart'; @@ -191,4 +194,22 @@ class PassMetadata { webServiceURL: json['webServiceURL'] as String?, ), ); + + /// Localized version of [description] based on given [locale] and [pass]. + String getLocalizedDescription(PassFile pass, Locale? locale) { + final localizations = pass.getLocalizations(locale); + return localizations?[description] ?? description; + } + + /// Localized version of [organizationName] based on given [locale] and [pass]. + String getLocalizedOrganizationName(PassFile pass, Locale? locale) { + final localizations = pass.getLocalizations(locale); + return localizations?[organizationName] ?? organizationName; + } + + /// Localized version of [logoText] based on given [locale] and [pass]. + String? getLocalizedLogoText(PassFile pass, Locale? locale) { + final localizations = pass.getLocalizations(locale); + return localizations?[logoText] ?? logoText; + } } diff --git a/lib/src/models/pass_structure_dictionary.dart b/lib/src/models/pass_structure_dictionary.dart index fc9e8fd..cd6eef0 100644 --- a/lib/src/models/pass_structure_dictionary.dart +++ b/lib/src/models/pass_structure_dictionary.dart @@ -1,3 +1,6 @@ +import 'package:intl/locale.dart'; + +import 'package:pkpass/pkpass.dart'; import 'package:pkpass/src/utils/mabe_decode.dart'; /// Keys that define the structure of the pass. @@ -108,11 +111,26 @@ class DictionaryField { ? null : DictionaryValue.parse(json['attributedValue'] as String), ); + + /// Localized version of [label] based on given [locale] and [pass]. + String? getLocalizedLabel(PassFile pass, Locale? locale) { + final localizations = pass.getLocalizations(locale); + print(localizations); + return localizations?[label] ?? label; + } + + /// Localized version of [changeMessage] based on given [locale] and [pass]. + String? getLocalizedChangeMessage(PassFile pass, Locale? locale) { + final localizations = pass.getLocalizations(locale); + return localizations?[changeMessage] ?? changeMessage; + } } +/// represents the possible values of a [DictionaryField]. abstract class DictionaryValue { const DictionaryValue(); + /// parses the correct [DictionaryValue] implementor based on a given [value]. factory DictionaryValue.parse(String value) { final number = int.tryParse(value); if (number != null) return NumberDictionaryValue(number); @@ -122,35 +140,82 @@ abstract class DictionaryValue { return StringDictionaryValue(value); } + + /// Localized value based on given [locale] and [pass]. + DictionaryValue getLocalizedValue(PassFile pass, Locale? locale); } +/// [String] content of a [DictionaryField]. class StringDictionaryValue extends DictionaryValue { final String string; const StringDictionaryValue(this.string); + + @override + + /// Localized value based on given [locale] and [pass]. + DictionaryValue getLocalizedValue(PassFile pass, Locale? locale) { + final localizations = pass.getLocalizations(locale); + return StringDictionaryValue(localizations?[string] ?? string); + } } +/// [DateTime] content of a [DictionaryField]. class DateTimeDictionaryValue extends DictionaryValue { final DateTime dateTime; const DateTimeDictionaryValue(this.dateTime); + + @override + + /// Localized value based on given [locale] and [pass]. Same as [dateTime]. + DictionaryValue getLocalizedValue(PassFile pass, Locale? locale) => this; } +/// [int] content of a [DictionaryField]. class NumberDictionaryValue extends DictionaryValue { final int number; const NumberDictionaryValue(this.number); + + @override + + /// Localized value based on given [locale] and [pass]. Same as [number]. + DictionaryValue getLocalizedValue(PassFile pass, Locale? locale) => this; } +/// Possible types of [PassStructureDictionary.transitType]. enum TransitType { + /// PKTransitTypeAir air, + + /// PKTransitTypeBoat boat, + + /// PKTransitTypeBus bus, + + /// PKTransitTypeGeneric generic, + + /// PKTransitTypeTrain train, } -enum PassTextAlign { left, center, right, natural } +/// Possible types of [DictionaryField.textAlignment]. +enum PassTextAlign { + /// PKTextAlignmentLeft, corresponds `dart:ui` [TextAlign.left] + left, + + /// PKTextAlignmentCenter, corresponds `dart:ui` [TextAlign.center] + center, + + /// PKTextAlignmentRight, corresponds `dart:ui` [TextAlign.left] + right, + + /// PKTextAlignmentNatural, corresponds `dart:ui` [TextAlign.start] + natural, +} extension _TarnsitType on TransitType { static TransitType? parse(String? type) { diff --git a/lib/src/models/pass_web_service.dart b/lib/src/models/pass_web_service.dart index 41d4140..bf6472f 100644 --- a/lib/src/models/pass_web_service.dart +++ b/lib/src/models/pass_web_service.dart @@ -16,6 +16,8 @@ class PassWebService { required this.webServiceURL, }); + /// returns a [PassWebService] in case [authenticationToken] and + /// [webServiceURL] are both valid values. static PassWebService? maybe({ String? authenticationToken, String? webServiceURL, diff --git a/lib/src/pass_file.dart b/lib/src/pass_file.dart index 2057112..747e3d5 100644 --- a/lib/src/pass_file.dart +++ b/lib/src/pass_file.dart @@ -6,13 +6,14 @@ import 'package:crypto/crypto.dart'; import 'package:intl/locale.dart'; import 'package:pkpass/pkpass.dart'; -import 'package:pkpass/src/file_matcher.dart'; +import 'package:pkpass/src/utils/file_matcher.dart'; +import 'package:pkpass/src/utils/lproj_parser.dart'; final _utf8codec = Utf8Codec(); final _jsonCodec = JsonCodec(); class PassFile { - PassFile(this.metadata, this._folder); + const PassFile(this.metadata, this._folder); static Future parse(Uint8List pass) async { final codec = ZipDecoder(); @@ -87,6 +88,7 @@ class PassFile { name: name, scale: scale, locale: locale, + extension: 'png', ); if (path == null) return null; final file = _folder.singleWhere((element) => element.name == path); @@ -96,16 +98,35 @@ class PassFile { Uint8List? getBackground({Locale? locale, int scale = 1}) => _matchUtf8List(name: 'background', locale: locale, scale: scale); + Uint8List? getFooter({Locale? locale, int scale = 1}) => _matchUtf8List(name: 'footer', locale: locale, scale: scale); + Uint8List? getIcon({Locale? locale, int scale = 1}) => _matchUtf8List(name: 'icon', locale: locale, scale: scale); + Uint8List? getLogo({Locale? locale, int scale = 1}) => _matchUtf8List(name: 'logo', locale: locale, scale: scale); + Uint8List? getStrip({Locale? locale, int scale = 1}) => _matchUtf8List(name: 'strip', locale: locale, scale: scale); + Uint8List? getThumbnail({Locale? locale, int scale = 1}) => _matchUtf8List(name: 'thumbnail', locale: locale, scale: scale); + + Map? getLocalizations(Locale? locale) { + final files = _folder.map((e) => e.name).toList(); + final paths = FileMatcher.matchLocale( + files: files, + name: 'pass', + extension: 'strings', + locale: locale, + ); + print(paths); + if (paths.isEmpty) return null; + final file = _folder.singleWhere((element) => element.name == paths.first); + return LProjParser.parse(file.stringContent); + } } extension on ArchiveFile { diff --git a/lib/src/file_matcher.dart b/lib/src/utils/file_matcher.dart similarity index 78% rename from lib/src/file_matcher.dart rename to lib/src/utils/file_matcher.dart index c960064..796723f 100644 --- a/lib/src/file_matcher.dart +++ b/lib/src/utils/file_matcher.dart @@ -26,19 +26,31 @@ abstract class FileMatcher { files = files.reversed.toList(); List expressions = []; - expressions.add( - RegExp( - '^$name(@\\d+x)?\\.$extension\$', - unicode: true, - caseSensitive: false, - ), + // adding the fallbacks + // - match just *any* language + // - match the five mostly spoken languages of the world, copied from Wikipedia + expressions.addAll( + [ + RegExp( + '^([a-z]+(-[a-z]+)?\\.lproj\\/)?$name(@\\d+x)?\\.$extension\$', + unicode: true, + caseSensitive: false, + ), + ...['en', 'zh', 'hi', 'es', 'fr'].reversed.map( + (language) => RegExp( + '^$language(-[a-z]+)?\\.lproj\\/$name(@\\d+x)?\\.$extension\$', + unicode: true, + caseSensitive: false, + ), + ), + ], ); if (locale != null) { final language = locale.languageCode; expressions.add( RegExp( - '^$language(-[a-z]+)?\\.lproj/$name(@\\d+x)?\\.$extension\$', + '^$language(-[a-z]+)?\\.lproj\\/$name(@\\d+x)?\\.$extension\$', unicode: true, caseSensitive: false, ), @@ -48,7 +60,7 @@ abstract class FileMatcher { if (region != null) { expressions.add( RegExp( - '^$language-$region\\.lproj/$name(@\\d+x)?\\.$extension\$', + '^$language-$region\\.lproj\\/$name(@\\d+x)?\\.$extension\$', unicode: true, caseSensitive: false, ), diff --git a/lib/src/utils/lproj_parser.dart b/lib/src/utils/lproj_parser.dart new file mode 100644 index 0000000..c6f2d85 --- /dev/null +++ b/lib/src/utils/lproj_parser.dart @@ -0,0 +1,10 @@ +abstract class LProjParser { + const LProjParser._(); + static Map parse(String stringsFile) => Map.fromEntries( + RegExp(r'"((?:\\"|[^"])*)"\s?=\s?"((?:\\"|[^"])*)"\s?;') + .allMatches(stringsFile) + .map( + (match) => MapEntry(match.group(1)!, match.group(2)!), + ), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index cb3bfd8..6536feb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pkpass description: A Dart native pkpass parsing library. version: 1.0.0 -repository: https://gitlab.com/TheOneWithTheBraid/dart_pkpass +repository: https://gitlab.com/TheOneWithTheBraid/dart_pkpass.git homepage: https://gitlab.com/TheOneWithTheBraid/dart_pkpass issue_tracker: https://gitlab.com/TheOneWithTheBraid/dart_pkpass/-/issues diff --git a/test/pkpass_test.dart b/test/pkpass_test.dart index 0394b90..5031fdc 100644 --- a/test/pkpass_test.dart +++ b/test/pkpass_test.dart @@ -5,7 +5,7 @@ import 'package:intl/locale.dart'; import 'package:test/test.dart'; import 'package:pkpass/pkpass.dart'; -import 'package:pkpass/src/file_matcher.dart'; +import 'package:pkpass/src/utils/file_matcher.dart'; void main() { final archive = [