001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.tools; 003 004import java.io.BufferedInputStream; 005import java.io.File; 006import java.io.IOException; 007import java.io.InputStream; 008import java.lang.annotation.Retention; 009import java.lang.annotation.RetentionPolicy; 010import java.net.URL; 011import java.nio.charset.StandardCharsets; 012import java.nio.file.InvalidPathException; 013import java.text.MessageFormat; 014import java.util.ArrayList; 015import java.util.Arrays; 016import java.util.Collection; 017import java.util.Comparator; 018import java.util.HashMap; 019import java.util.Locale; 020import java.util.Map; 021import java.util.zip.ZipEntry; 022import java.util.zip.ZipFile; 023 024/** 025 * Internationalisation support. 026 * 027 * @author Immanuel.Scholz 028 */ 029public final class I18n { 030 031 /** 032 * This annotates strings which do not permit a clean i18n. This is mostly due to strings 033 * containing two nouns which can occur in singular or plural form. 034 * <br> 035 * No behaviour is associated with this annotation. 036 */ 037 @Retention(RetentionPolicy.SOURCE) 038 public @interface QuirkyPluralString { 039 } 040 041 private I18n() { 042 // Hide default constructor for utils classes 043 } 044 045 /** 046 * Enumeration of possible plural modes. It allows us to identify and implement logical conditions of 047 * plural forms defined on <a href="https://help.launchpad.net/Translations/PluralForms">Launchpad</a>. 048 * See <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html">CLDR</a> 049 * for another complete list. 050 * @see #pluralEval 051 */ 052 private enum PluralMode { 053 /** Plural = Not 1. This is the default for many languages, including English: 1 day, but 0 days or 2 days. */ 054 MODE_NOTONE, 055 /** No plural. Mainly for Asian languages (Indonesian, Chinese, Japanese, ...) */ 056 MODE_NONE, 057 /** Plural = Greater than 1. For some latin languages (French, Brazilian Portuguese) */ 058 MODE_GREATERONE, 059 /* Special mode for 060 * <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#ar">Arabic</a>.* 061 MODE_AR,*/ 062 /** Special mode for 063 * <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#cs">Czech</a>. */ 064 MODE_CS, 065 /** Special mode for 066 * <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#pl">Polish</a>. */ 067 MODE_PL, 068 /* Special mode for 069 * <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#ro">Romanian</a>.* 070 MODE_RO,*/ 071 /** Special mode for 072 * <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#lt">Lithuanian</a>. */ 073 MODE_LT, 074 /** Special mode for 075 * <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#ru">Russian</a>. */ 076 MODE_RU, 077 /** Special mode for 078 * <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#sk">Slovak</a>. */ 079 MODE_SK, 080 /* Special mode for 081 * <a href="http://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html#sl">Slovenian</a>.* 082 MODE_SL,*/ 083 } 084 085 private static volatile PluralMode pluralMode = PluralMode.MODE_NOTONE; /* english default */ 086 private static volatile String loadedCode = "en"; 087 088 089 private static volatile Map<String, String> strings; 090 private static volatile Map<String, String[]> pstrings; 091 private static Map<String, PluralMode> languages = new HashMap<>(); 092 static { 093 //languages.put("ar", PluralMode.MODE_AR); 094 languages.put("ast", PluralMode.MODE_NOTONE); 095 languages.put("bg", PluralMode.MODE_NOTONE); 096 languages.put("be", PluralMode.MODE_RU); 097 languages.put("ca", PluralMode.MODE_NOTONE); 098 languages.put("ca@valencia", PluralMode.MODE_NOTONE); 099 languages.put("cs", PluralMode.MODE_CS); 100 languages.put("da", PluralMode.MODE_NOTONE); 101 languages.put("de", PluralMode.MODE_NOTONE); 102 languages.put("el", PluralMode.MODE_NOTONE); 103 languages.put("en_AU", PluralMode.MODE_NOTONE); 104 languages.put("en_GB", PluralMode.MODE_NOTONE); 105 languages.put("es", PluralMode.MODE_NOTONE); 106 languages.put("et", PluralMode.MODE_NOTONE); 107 //languages.put("eu", PluralMode.MODE_NOTONE); 108 languages.put("fi", PluralMode.MODE_NOTONE); 109 languages.put("fr", PluralMode.MODE_GREATERONE); 110 languages.put("gl", PluralMode.MODE_NOTONE); 111 //languages.put("he", PluralMode.MODE_NOTONE); 112 languages.put("hu", PluralMode.MODE_NOTONE); 113 languages.put("id", PluralMode.MODE_NONE); 114 //languages.put("is", PluralMode.MODE_NOTONE); 115 languages.put("it", PluralMode.MODE_NOTONE); 116 languages.put("ja", PluralMode.MODE_NONE); 117 // fully supported only with Java 8 and later (needs CLDR) 118 languages.put("km", PluralMode.MODE_NONE); 119 languages.put("lt", PluralMode.MODE_LT); 120 languages.put("nb", PluralMode.MODE_NOTONE); 121 languages.put("nl", PluralMode.MODE_NOTONE); 122 languages.put("pl", PluralMode.MODE_PL); 123 languages.put("pt", PluralMode.MODE_NOTONE); 124 languages.put("pt_BR", PluralMode.MODE_GREATERONE); 125 //languages.put("ro", PluralMode.MODE_RO); 126 languages.put("ru", PluralMode.MODE_RU); 127 languages.put("sk", PluralMode.MODE_SK); 128 //languages.put("sl", PluralMode.MODE_SL); 129 languages.put("sv", PluralMode.MODE_NOTONE); 130 //languages.put("tr", PluralMode.MODE_NONE); 131 languages.put("uk", PluralMode.MODE_RU); 132 languages.put("vi", PluralMode.MODE_NONE); 133 languages.put("zh_CN", PluralMode.MODE_NONE); 134 languages.put("zh_TW", PluralMode.MODE_NONE); 135 } 136 137 /** 138 * Translates some text for the current locale. 139 * These strings are collected by a script that runs on the source code files. 140 * After translation, the localizations are distributed with the main program. 141 * <br> 142 * For example, <code>tr("JOSM''s default value is ''{0}''.", val)</code>. 143 * <br> 144 * Use {@link #trn} for distinguishing singular from plural text, i.e., 145 * do not use {@code tr(size == 1 ? "singular" : "plural")} nor 146 * {@code size == 1 ? tr("singular") : tr("plural")} 147 * 148 * @param text the text to translate. 149 * Must be a string literal. (No constants or local vars.) 150 * Can be broken over multiple lines. 151 * An apostrophe ' must be quoted by another apostrophe. 152 * @param objects the parameters for the string. 153 * Mark occurrences in {@code text} with <code>{0}</code>, <code>{1}</code>, ... 154 * @return the translated string. 155 * @see #trn 156 * @see #trc 157 * @see #trnc 158 */ 159 public static String tr(String text, Object... objects) { 160 if (text == null) return null; 161 return MessageFormat.format(gettext(text, null), objects); 162 } 163 164 /** 165 * Translates some text in a context for the current locale. 166 * There can be different translations for the same text within different contexts. 167 * 168 * @param context string that helps translators to find an appropriate 169 * translation for {@code text}. 170 * @param text the text to translate. 171 * @return the translated string. 172 * @see #tr 173 * @see #trn 174 * @see #trnc 175 */ 176 public static String trc(String context, String text) { 177 if (context == null) 178 return tr(text); 179 if (text == null) 180 return null; 181 return MessageFormat.format(gettext(text, context), (Object) null); 182 } 183 184 public static String trcLazy(String context, String text) { 185 if (context == null) 186 return tr(text); 187 if (text == null) 188 return null; 189 return MessageFormat.format(gettextLazy(text, context), (Object) null); 190 } 191 192 /** 193 * Marks a string for translation (such that a script can harvest 194 * the translatable strings from the source files). 195 * 196 * For example, <code> 197 * String[] options = new String[] {marktr("up"), marktr("down")}; 198 * lbl.setText(tr(options[0]));</code> 199 * @param text the string to be marked for translation. 200 * @return {@code text} unmodified. 201 */ 202 public static String marktr(String text) { 203 return text; 204 } 205 206 public static String marktrc(String context, String text) { 207 return text; 208 } 209 210 /** 211 * Translates some text for the current locale and distinguishes between 212 * {@code singularText} and {@code pluralText} depending on {@code n}. 213 * <br> 214 * For instance, {@code trn("There was an error!", "There were errors!", i)} or 215 * <code>trn("Found {0} error in {1}!", "Found {0} errors in {1}!", i, Integer.toString(i), url)</code>. 216 * 217 * @param singularText the singular text to translate. 218 * Must be a string literal. (No constants or local vars.) 219 * Can be broken over multiple lines. 220 * An apostrophe ' must be quoted by another apostrophe. 221 * @param pluralText the plural text to translate. 222 * Must be a string literal. (No constants or local vars.) 223 * Can be broken over multiple lines. 224 * An apostrophe ' must be quoted by another apostrophe. 225 * @param n a number to determine whether {@code singularText} or {@code pluralText} is used. 226 * @param objects the parameters for the string. 227 * Mark occurrences in {@code singularText} and {@code pluralText} with <code>{0}</code>, <code>{1}</code>, ... 228 * @return the translated string. 229 * @see #tr 230 * @see #trc 231 * @see #trnc 232 */ 233 public static String trn(String singularText, String pluralText, long n, Object... objects) { 234 return MessageFormat.format(gettextn(singularText, pluralText, null, n), objects); 235 } 236 237 /** 238 * Translates some text in a context for the current locale and distinguishes between 239 * {@code singularText} and {@code pluralText} depending on {@code n}. 240 * There can be different translations for the same text within different contexts. 241 * 242 * @param context string that helps translators to find an appropriate 243 * translation for {@code text}. 244 * @param singularText the singular text to translate. 245 * Must be a string literal. (No constants or local vars.) 246 * Can be broken over multiple lines. 247 * An apostrophe ' must be quoted by another apostrophe. 248 * @param pluralText the plural text to translate. 249 * Must be a string literal. (No constants or local vars.) 250 * Can be broken over multiple lines. 251 * An apostrophe ' must be quoted by another apostrophe. 252 * @param n a number to determine whether {@code singularText} or {@code pluralText} is used. 253 * @param objects the parameters for the string. 254 * Mark occurrences in {@code singularText} and {@code pluralText} with <code>{0}</code>, <code>{1}</code>, ... 255 * @return the translated string. 256 * @see #tr 257 * @see #trc 258 * @see #trn 259 */ 260 public static String trnc(String context, String singularText, String pluralText, long n, Object... objects) { 261 return MessageFormat.format(gettextn(singularText, pluralText, context, n), objects); 262 } 263 264 private static String gettext(String text, String ctx, boolean lazy) { 265 int i; 266 if (ctx == null && text.startsWith("_:") && (i = text.indexOf('\n')) >= 0) { 267 ctx = text.substring(2, i-1); 268 text = text.substring(i+1); 269 } 270 if (strings != null) { 271 String trans = strings.get(ctx == null ? text : "_:"+ctx+'\n'+text); 272 if (trans != null) 273 return trans; 274 } 275 if (pstrings != null) { 276 i = pluralEval(1); 277 String[] trans = pstrings.get(ctx == null ? text : "_:"+ctx+'\n'+text); 278 if (trans != null && trans.length > i) 279 return trans[i]; 280 } 281 return lazy ? gettext(text, null) : text; 282 } 283 284 private static String gettext(String text, String ctx) { 285 return gettext(text, ctx, false); 286 } 287 288 /* try without context, when context try fails */ 289 private static String gettextLazy(String text, String ctx) { 290 return gettext(text, ctx, true); 291 } 292 293 private static String gettextn(String text, String plural, String ctx, long num) { 294 int i; 295 if (ctx == null && text.startsWith("_:") && (i = text.indexOf('\n')) >= 0) { 296 ctx = text.substring(2, i-1); 297 text = text.substring(i+1); 298 } 299 if (pstrings != null) { 300 i = pluralEval(num); 301 String[] trans = pstrings.get(ctx == null ? text : "_:"+ctx+'\n'+text); 302 if (trans != null && trans.length > i) 303 return trans[i]; 304 } 305 306 return num == 1 ? text : plural; 307 } 308 309 public static String escape(String msg) { 310 if (msg == null) return null; 311 return msg.replace("\'", "\'\'").replace("{", "\'{\'").replace("}", "\'}\'"); 312 } 313 314 private static URL getTranslationFile(String lang) { 315 return I18n.class.getResource("/data/"+lang.replace('@', '-')+".lang"); 316 } 317 318 /** 319 * Get a list of all available JOSM Translations. 320 * @return an array of locale objects. 321 */ 322 public static Locale[] getAvailableTranslations() { 323 Collection<Locale> v = new ArrayList<>(languages.size()); 324 if (getTranslationFile("en") != null) { 325 for (String loc : languages.keySet()) { 326 if (getTranslationFile(loc) != null) { 327 v.add(LanguageInfo.getLocale(loc)); 328 } 329 } 330 } 331 v.add(Locale.ENGLISH); 332 Locale[] l = new Locale[v.size()]; 333 l = v.toArray(l); 334 Arrays.sort(l, Comparator.comparing(Locale::toString)); 335 return l; 336 } 337 338 /** 339 * Determines if a language exists for the given code. 340 * @param code The language code 341 * @return {@code true} if a language exists, {@code false} otherwise 342 */ 343 public static boolean hasCode(String code) { 344 return languages.containsKey(code); 345 } 346 347 static void setupJavaLocaleProviders() { 348 // Look up SPI providers first (for JosmDecimalFormatSymbolsProvider). 349 // Enable CLDR locale provider on Java 8 to get additional languages, such as Khmer. 350 // http://docs.oracle.com/javase/8/docs/technotes/guides/intl/enhancements.8.html#cldr 351 // FIXME: This must be updated after we switch to Java 9. 352 // See https://docs.oracle.com/javase/9/docs/api/java/util/spi/LocaleServiceProvider.html 353 try { 354 // Don't call Utils.updateSystemProperty to avoid spurious log at startup 355 System.setProperty("java.locale.providers", "SPI,JRE,CLDR"); 356 } catch (SecurityException e) { 357 // Don't call Logging class, it may not be fully initialized yet 358 System.err.println("Unable to set locale providers: " + e.getMessage()); 359 } 360 } 361 362 /** 363 * I18n initialization. 364 */ 365 public static void init() { 366 setupJavaLocaleProviders(); 367 368 /* try initial language settings, may be changed later again */ 369 if (!load(LanguageInfo.getJOSMLocaleCode())) { 370 Locale.setDefault(Locale.ENGLISH); 371 } 372 } 373 374 /** 375 * I18n initialization for plugins. 376 * @param source file path/name of the JAR or Zip file containing translation strings 377 * @since 4159 378 */ 379 public static void addTexts(File source) { 380 if ("en".equals(loadedCode)) 381 return; 382 final ZipEntry enfile = new ZipEntry("data/en.lang"); 383 final ZipEntry langfile = new ZipEntry("data/"+loadedCode+".lang"); 384 try ( 385 ZipFile zipFile = new ZipFile(source, StandardCharsets.UTF_8); 386 InputStream orig = zipFile.getInputStream(enfile); 387 InputStream trans = zipFile.getInputStream(langfile) 388 ) { 389 if (orig != null && trans != null) 390 load(orig, trans, true); 391 } catch (IOException | InvalidPathException e) { 392 Logging.trace(e); 393 } 394 } 395 396 private static boolean load(String l) { 397 if ("en".equals(l) || "en_US".equals(l)) { 398 strings = null; 399 pstrings = null; 400 loadedCode = "en"; 401 pluralMode = PluralMode.MODE_NOTONE; 402 return true; 403 } 404 URL en = getTranslationFile("en"); 405 if (en == null) 406 return false; 407 URL tr = getTranslationFile(l); 408 if (tr == null || !languages.containsKey(l)) { 409 return false; 410 } 411 try ( 412 InputStream enStream = Utils.openStream(en); 413 InputStream trStream = Utils.openStream(tr) 414 ) { 415 if (load(enStream, trStream, false)) { 416 pluralMode = languages.get(l); 417 loadedCode = l; 418 return true; 419 } 420 } catch (IOException e) { 421 // Ignore exception 422 Logging.trace(e); 423 } 424 return false; 425 } 426 427 private static boolean load(InputStream en, InputStream tr, boolean add) { 428 Map<String, String> s; 429 Map<String, String[]> p; 430 if (add) { 431 s = strings; 432 p = pstrings; 433 } else { 434 s = new HashMap<>(); 435 p = new HashMap<>(); 436 } 437 /* file format: 438 Files are always a group. English file and translated file must provide identical datasets. 439 440 for all single strings: 441 { 442 unsigned short (2 byte) stringlength 443 - length 0 indicates missing translation 444 - length 0xFFFE indicates translation equal to original, but otherwise is equal to length 0 445 string 446 } 447 unsigned short (2 byte) 0xFFFF (marks end of single strings) 448 for all multi strings: 449 { 450 unsigned char (1 byte) stringcount 451 - count 0 indicates missing translations 452 - count 0xFE indicates translations equal to original, but otherwise is equal to length 0 453 for stringcount 454 unsigned short (2 byte) stringlength 455 string 456 } 457 */ 458 try { 459 InputStream ens = new BufferedInputStream(en); 460 InputStream trs = new BufferedInputStream(tr); 461 byte[] enlen = new byte[2]; 462 byte[] trlen = new byte[2]; 463 boolean multimode = false; 464 byte[] str = new byte[4096]; 465 for (;;) { 466 if (multimode) { 467 int ennum = ens.read(); 468 int trnum = trs.read(); 469 if (trnum == 0xFE) /* marks identical string, handle equally to non-translated */ 470 trnum = 0; 471 if ((ennum == -1 && trnum != -1) || (ennum != -1 && trnum == -1)) /* files do not match */ 472 return false; 473 if (ennum == -1) { 474 break; 475 } 476 String[] enstrings = new String[ennum]; 477 for (int i = 0; i < ennum; ++i) { 478 int val = ens.read(enlen); 479 if (val != 2) /* file corrupt */ 480 return false; 481 val = (enlen[0] < 0 ? 256+enlen[0] : enlen[0])*256+(enlen[1] < 0 ? 256+enlen[1] : enlen[1]); 482 if (val > str.length) { 483 str = new byte[val]; 484 } 485 int rval = ens.read(str, 0, val); 486 if (rval != val) /* file corrupt */ 487 return false; 488 enstrings[i] = new String(str, 0, val, StandardCharsets.UTF_8); 489 } 490 String[] trstrings = new String[trnum]; 491 for (int i = 0; i < trnum; ++i) { 492 int val = trs.read(trlen); 493 if (val != 2) /* file corrupt */ 494 return false; 495 val = (trlen[0] < 0 ? 256+trlen[0] : trlen[0])*256+(trlen[1] < 0 ? 256+trlen[1] : trlen[1]); 496 if (val > str.length) { 497 str = new byte[val]; 498 } 499 int rval = trs.read(str, 0, val); 500 if (rval != val) /* file corrupt */ 501 return false; 502 trstrings[i] = new String(str, 0, val, StandardCharsets.UTF_8); 503 } 504 if (trnum > 0 && !p.containsKey(enstrings[0])) { 505 p.put(enstrings[0], trstrings); 506 } 507 } else { 508 int enval = ens.read(enlen); 509 int trval = trs.read(trlen); 510 if (enval != trval) /* files do not match */ 511 return false; 512 if (enval == -1) { 513 break; 514 } 515 if (enval != 2) /* files corrupt */ 516 return false; 517 enval = (enlen[0] < 0 ? 256+enlen[0] : enlen[0])*256+(enlen[1] < 0 ? 256+enlen[1] : enlen[1]); 518 trval = (trlen[0] < 0 ? 256+trlen[0] : trlen[0])*256+(trlen[1] < 0 ? 256+trlen[1] : trlen[1]); 519 if (trval == 0xFFFE) /* marks identical string, handle equally to non-translated */ 520 trval = 0; 521 if (enval == 0xFFFF) { 522 multimode = true; 523 if (trval != 0xFFFF) /* files do not match */ 524 return false; 525 } else { 526 if (enval > str.length) { 527 str = new byte[enval]; 528 } 529 if (trval > str.length) { 530 str = new byte[trval]; 531 } 532 int val = ens.read(str, 0, enval); 533 if (val != enval) /* file corrupt */ 534 return false; 535 String enstr = new String(str, 0, enval, StandardCharsets.UTF_8); 536 if (trval != 0) { 537 val = trs.read(str, 0, trval); 538 if (val != trval) /* file corrupt */ 539 return false; 540 String trstr = new String(str, 0, trval, StandardCharsets.UTF_8); 541 if (!s.containsKey(enstr)) 542 s.put(enstr, trstr); 543 } 544 } 545 } 546 } 547 } catch (IOException e) { 548 Logging.trace(e); 549 return false; 550 } 551 if (!s.isEmpty()) { 552 strings = s; 553 pstrings = p; 554 return true; 555 } 556 return false; 557 } 558 559 /** 560 * Sets the default locale (see {@link Locale#setDefault(Locale)} to the local 561 * given by <code>localName</code>. 562 * 563 * Ignored if localeName is null. If the locale with name <code>localName</code> 564 * isn't found the default local is set to <code>en</code> (english). 565 * 566 * @param localeName the locale name. Ignored if null. 567 */ 568 public static void set(String localeName) { 569 if (localeName != null) { 570 Locale l = LanguageInfo.getLocale(localeName); 571 if (load(LanguageInfo.getJOSMLocaleCode(l))) { 572 Locale.setDefault(l); 573 } else { 574 if (!"en".equals(l.getLanguage())) { 575 Logging.info(tr("Unable to find translation for the locale {0}. Reverting to {1}.", 576 LanguageInfo.getDisplayName(l), LanguageInfo.getDisplayName(Locale.getDefault()))); 577 } else { 578 strings = null; 579 pstrings = null; 580 } 581 } 582 } 583 } 584 585 private static int pluralEval(long n) { 586 switch(pluralMode) { 587 case MODE_NOTONE: /* bg, da, de, el, en, en_GB, es, et, eu, fi, gl, is, it, iw_IL, nb, nl, sv */ 588 return (n != 1) ? 1 : 0; 589 case MODE_NONE: /* id, vi, ja, km, tr, zh_CN, zh_TW */ 590 return 0; 591 case MODE_GREATERONE: /* fr, pt_BR */ 592 return (n > 1) ? 1 : 0; 593 case MODE_CS: 594 return (n == 1) ? 0 : (((n >= 2) && (n <= 4)) ? 1 : 2); 595 //case MODE_AR: 596 // return ((n == 0) ? 0 : ((n == 1) ? 1 : ((n == 2) ? 2 : ((((n % 100) >= 3) 597 // && ((n % 100) <= 10)) ? 3 : ((((n % 100) >= 11) && ((n % 100) <= 99)) ? 4 : 5))))); 598 case MODE_PL: 599 return (n == 1) ? 0 : (((((n % 10) >= 2) && ((n % 10) <= 4)) 600 && (((n % 100) < 10) || ((n % 100) >= 20))) ? 1 : 2); 601 //case MODE_RO: 602 // return ((n == 1) ? 0 : ((((n % 100) > 19) || (((n % 100) == 0) && (n != 0))) ? 2 : 1)); 603 case MODE_LT: 604 return ((n % 10) == 1) && ((n % 100) != 11) ? 0 : (((n % 10) >= 2) 605 && (((n % 100) < 10) || ((n % 100) >= 20)) ? 1 : 2); 606 case MODE_RU: 607 return (((n % 10) == 1) && ((n % 100) != 11)) ? 0 : (((((n % 10) >= 2) 608 && ((n % 10) <= 4)) && (((n % 100) < 10) || ((n % 100) >= 20))) ? 1 : 2); 609 case MODE_SK: 610 return (n == 1) ? 1 : (((n >= 2) && (n <= 4)) ? 2 : 0); 611 //case MODE_SL: 612 // return (((n % 100) == 1) ? 1 : (((n % 100) == 2) ? 2 : ((((n % 100) == 3) 613 // || ((n % 100) == 4)) ? 3 : 0))); 614 } 615 return 0; 616 } 617}