1# Copyright 2004-2021 Tom Rothamel <pytom@bishoujo.us> 2# 3# Permission is hereby granted, free of charge, to any person 4# obtaining a copy of this software and associated documentation files 5# (the "Software"), to deal in the Software without restriction, 6# including without limitation the rights to use, copy, modify, merge, 7# publish, distribute, sublicense, and/or sell copies of the Software, 8# and to permit persons to whom the Software is furnished to do so, 9# subject to the following conditions: 10# 11# The above copyright notice and this permission notice shall be 12# included in all copies or substantial portions of the Software. 13# 14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 22from __future__ import division, absolute_import, with_statement, print_function, unicode_literals 23from renpy.compat import * 24 25import renpy.translation 26 27import re 28import os 29import time 30import collections 31import shutil 32 33from renpy.translation import quote_unicode 34from renpy.parser import elide_filename 35 36################################################################################ 37# Translation Generation 38################################################################################ 39 40 41def scan_comments(filename): 42 43 rv = [ ] 44 45 if filename not in renpy.config.translate_comments: 46 return rv 47 48 comment = [ ] 49 start = 0 50 51 with open(filename, "r", encoding="utf-8") as f: 52 lines = [ i.rstrip() for i in f.read().replace(u"\ufeff", "").split('\n') ] 53 54 for i, l in enumerate(lines): 55 56 if not comment: 57 start = i + 1 58 59 m = re.match(r'\s*## (.*)', l) 60 61 if m: 62 c = m.group(1) 63 64 if comment: 65 c = c.strip() 66 67 comment.append(c) 68 69 elif comment: 70 s = "## " + " ".join(comment) 71 72 if s.endswith("#"): 73 s = s.rstrip("# ") 74 75 comment = [ ] 76 77 rv.append((start, s)) 78 79 return rv 80 81 82tl_file_cache = { } 83 84# Should we write the TODO marker? 85todo = True 86 87 88def open_tl_file(fn): 89 90 if fn in tl_file_cache: 91 return tl_file_cache[fn] 92 93 if not os.path.exists(fn): 94 dn = os.path.dirname(fn) 95 96 try: 97 os.makedirs(dn) 98 except: 99 pass 100 101 f = open(fn, "a", encoding="utf-8") 102 f.write(u"\ufeff") 103 104 else: 105 f = open(fn, "a", encoding="utf-8") 106 107 if todo: 108 f.write(u"# TO" + "DO: Translation updated at {}\n".format(time.strftime("%Y-%m-%d %H:%M"))) 109 110 f.write(u"\n") 111 112 tl_file_cache[fn] = f 113 114 return f 115 116 117def close_tl_files(): 118 119 for i in tl_file_cache.values(): 120 i.close() 121 122 tl_file_cache.clear() 123 124 125def shorten_filename(filename): 126 """ 127 Shortens a file name. Returns the shortened filename, and a flag that says 128 if the filename is in the common directory. 129 """ 130 131 commondir = os.path.normpath(renpy.config.commondir) 132 gamedir = os.path.normpath(renpy.config.gamedir) 133 134 if filename.startswith(commondir): 135 fn = os.path.relpath(filename, commondir) 136 common = True 137 138 elif filename.startswith(gamedir): 139 fn = os.path.relpath(filename, gamedir) 140 common = False 141 142 else: 143 fn = os.path.basename(filename) 144 common = False 145 146 return fn, common 147 148 149def write_translates(filename, language, filter): # @ReservedAssignment 150 151 fn, common = shorten_filename(filename) 152 153 # The common directory should not have dialogue in it. 154 if common: 155 return 156 157 tl_filename = os.path.join(renpy.config.gamedir, renpy.config.tl_directory, language, fn) 158 159 if tl_filename[-1] == "m": 160 tl_filename = tl_filename[:-1] 161 162 if language == "None": 163 language = None 164 165 translator = renpy.game.script.translator 166 167 for label, t in translator.file_translates[filename]: 168 169 if (t.identifier, language) in translator.language_translates: 170 continue 171 172 if hasattr(t, "alternate"): 173 if (t.alternate, language) in translator.language_translates: 174 continue 175 176 f = open_tl_file(tl_filename) 177 178 if label is None: 179 label = "" 180 181 f.write(u"# {}:{}\n".format(t.filename, t.linenumber)) 182 f.write(u"translate {} {}:\n".format(language, t.identifier.replace('.', '_'))) 183 f.write(u"\n") 184 185 for n in t.block: 186 f.write(u" # " + n.get_code() + "\n") 187 188 for n in t.block: 189 f.write(u" " + n.get_code(filter) + "\n") 190 191 f.write(u"\n") 192 193 194def translation_filename(s): 195 196 if renpy.config.translate_launcher: 197 return s.launcher_file 198 199 if s.common: 200 return "common.rpy" 201 202 filename = s.elided 203 204 if filename[-1] == "m": 205 filename = filename[:-1] 206 207 return filename 208 209 210def write_strings(language, filter, min_priority, max_priority, common_only): # @ReservedAssignment 211 """ 212 Writes strings to the file. 213 """ 214 215 if language == "None": 216 stl = renpy.game.script.translator.strings[None] # @UndefinedVariable 217 else: 218 stl = renpy.game.script.translator.strings[language] # @UndefinedVariable 219 220 # If this function changes, count_missing may also need to 221 # change. 222 223 strings = renpy.translation.scanstrings.scan(min_priority, max_priority, common_only) 224 225 stringfiles = collections.defaultdict(list) 226 227 for s in strings: 228 229 tlfn = translation_filename(s) 230 231 if tlfn is None: 232 continue 233 234 # Already seen. 235 if s.text in stl.translations: 236 continue 237 238 if language == "None" and tlfn == "common.rpy": 239 tlfn = "common.rpym" 240 241 stringfiles[tlfn].append(s) 242 243 for tlfn, sl in stringfiles.items(): 244 245 # sl.sort(key=lambda s : (s.filename, s.line)) 246 247 tlfn = os.path.join(renpy.config.gamedir, renpy.config.tl_directory, language, tlfn) 248 f = open_tl_file(tlfn) 249 250 f.write(u"translate {} strings:\n".format(language)) 251 f.write(u"\n") 252 253 for s in sl: 254 text = filter(s.text) 255 256 f.write(u" # {}:{}\n".format(elide_filename(s.filename), s.line)) 257 f.write(u" old \"{}\"\n".format(quote_unicode(s.text))) 258 f.write(u" new \"{}\"\n".format(quote_unicode(text))) 259 f.write(u"\n") 260 261 262def null_filter(s): 263 return s 264 265 266def empty_filter(s): 267 return "" 268 269 270def generic_filter(s, function): 271 """ 272 :doc: text_utility 273 274 Transforms `s`, while leaving text tags and interpolation the same. 275 276 `function` 277 A function that is called with strings corresponding to runs of 278 text, and should return a second string that replaces that run 279 of text. 280 281 :: 282 283 init python: 284 def upper(s): 285 return s.upper() 286 287 $ upper_string = renpy.transform_text("{b}Not Upper{/b}", upper) 288 289 """ 290 291 def remove_special(s, start, end, process): 292 specials = 0 293 first = False 294 295 rv = "" 296 buf = "" 297 298 for i in s: 299 300 if i == start: 301 if first: 302 specials = 0 303 else: 304 rv += process(buf) 305 buf = "" 306 307 if specials == 0: 308 first = True 309 310 specials += 1 311 312 rv += start 313 314 elif i == end: 315 316 first = False 317 318 specials -= 1 319 if specials < 0: 320 specials += 1 321 322 rv += end 323 324 else: 325 if specials: 326 rv += i 327 else: 328 buf += i 329 330 if buf: 331 rv += process(buf) 332 333 return rv 334 335 def remove_braces(s): 336 return remove_special(s, "{", "}", function) 337 338 return remove_special(s, "[", "]", remove_braces) 339 340 341def rot13_transform(s): 342 343 ROT13 = { } 344 345 for i, j in zip("ABCDEFGHIJKLM", "NOPQRSTUVWXYZ"): 346 ROT13[i] = j 347 ROT13[j] = i 348 349 i = i.lower() 350 j = j.lower() 351 352 ROT13[i] = j 353 ROT13[j] = i 354 355 return "".join(ROT13.get(i, i) for i in s) 356 357 358def rot13_filter(s): 359 return generic_filter(s, rot13_transform) 360 361 362def piglatin_transform(s): 363 # Based on http://stackoverflow.com/a/23177629/3549890 364 365 lst = ['sh', 'gl', 'ch', 'ph', 'tr', 'br', 'fr', 'bl', 'gr', 'st', 'sl', 'cl', 'pl', 'fl'] 366 367 def replace(m): 368 i = m.group(0) 369 370 if i[0] in ['a', 'e', 'i', 'o', 'u']: 371 rv = i + 'ay' 372 elif i[:2] in lst: 373 rv = i[2:] + i[:2] + 'ay' 374 else: 375 rv = i[1:] + i[0] + 'ay' 376 377 if i[0].isupper(): 378 rv = rv.capitalize() 379 380 return rv 381 382 return re.sub(r'\w+', replace, s) 383 384 385def piglatin_filter(s): 386 return generic_filter(s, piglatin_transform) 387 388 389def translate_list_files(): 390 """ 391 Returns a list of files that exist and should be scanned for translations. 392 """ 393 394 filenames = list(renpy.config.translate_files) 395 396 for dirname, filename in renpy.loader.listdirfiles(): 397 if dirname is None: 398 continue 399 400 if filename.startswith("tl/"): 401 continue 402 403 filename = os.path.join(dirname, filename) 404 405 if not (filename.endswith(".rpy") or filename.endswith(".rpym")): 406 continue 407 408 filename = os.path.normpath(filename) 409 410 if not os.path.exists(filename): 411 continue 412 413 filenames.append(filename) 414 415 return filenames 416 417 418def count_missing(language, min_priority, max_priority, common_only): 419 """ 420 Prints a count of missing translations for `language`. 421 """ 422 423 translator = renpy.game.script.translator 424 425 missing_translates = 0 426 427 for filename in translate_list_files(): 428 for _, t in translator.file_translates[filename]: 429 if (t.identifier, language) not in translator.language_translates: 430 missing_translates += 1 431 432 missing_strings = 0 433 434 stl = renpy.game.script.translator.strings[language] # @UndefinedVariable 435 436 strings = renpy.translation.scanstrings.scan(min_priority, max_priority, common_only) 437 438 for s in strings: 439 440 tlfn = translation_filename(s) 441 442 if tlfn is None: 443 continue 444 445 if s.text in stl.translations: 446 continue 447 448 missing_strings += 1 449 450 print("{}: {} missing dialogue translations, {} missing string translations.".format( 451 language, 452 missing_translates, 453 missing_strings 454 )) 455 456 457def translate_command(): 458 """ 459 The translate command. When called from the command line, this generates 460 the translations. 461 """ 462 463 ap = renpy.arguments.ArgumentParser(description="Generates or updates translations.") 464 ap.add_argument("language", help="The language to generate translations for.") 465 ap.add_argument("--rot13", help="Apply rot13 while generating translations.", dest="rot13", action="store_true") 466 ap.add_argument("--piglatin", help="Apply pig latin while generating translations.", dest="piglatin", action="store_true") 467 ap.add_argument("--empty", help="Produce empty strings while generating translations.", dest="empty", action="store_true") 468 ap.add_argument("--count", help="Instead of generating files, print a count of missing translations.", dest="count", action="store_true") 469 ap.add_argument("--min-priority", help="Translate strings with more than this priority.", dest="min_priority", default=0, type=int) 470 ap.add_argument("--max-priority", help="Translate strings with more than this priority.", dest="max_priority", default=0, type=int) 471 ap.add_argument("--strings-only", help="Only translate strings (not dialogue).", dest="strings_only", default=False, action="store_true") 472 ap.add_argument("--common-only", help="Only translate string from the common code.", dest="common_only", default=False, action="store_true") 473 ap.add_argument("--no-todo", help="Do not include the TODO flag.", dest="todo", default=True, action="store_false") 474 475 args = ap.parse_args() 476 477 global todo 478 todo = args.todo 479 480 if renpy.config.translate_launcher: 481 max_priority = args.max_priority or 499 482 else: 483 max_priority = args.max_priority or 299 484 485 if args.count: 486 count_missing(args.language, args.min_priority, max_priority, args.common_only) 487 return False 488 489 if args.rot13: 490 filter = rot13_filter # @ReservedAssignment 491 elif args.piglatin: 492 filter = piglatin_filter # @ReservedAssignment 493 elif args.empty: 494 filter = empty_filter # @ReservedAssignment 495 else: 496 filter = null_filter # @ReservedAssignment 497 498 if not args.strings_only: 499 for filename in translate_list_files(): 500 write_translates(filename, args.language, filter) 501 502 write_strings(args.language, filter, args.min_priority, max_priority, args.common_only) 503 504 close_tl_files() 505 506 if renpy.config.translate_launcher and (not args.strings_only): 507 src = os.path.join(renpy.config.renpy_base, "gui", "game", "script.rpy") 508 dst = os.path.join(renpy.config.gamedir, "tl", args.language, "script.rpym") 509 510 if os.path.exists(src) and not os.path.exists(dst): 511 shutil.copy(src, dst) 512 513 return False 514 515 516renpy.arguments.register_command("translate", translate_command) 517