trans.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. import logging
  2. import re
  3. from transliterator.exceptions import BREAK, CONT
  4. from transliterator.tables import load_table
  5. # Match multiple spaces.
  6. MULTI_WS_RE = re.compile(r"\s{2,}")
  7. logger = logging.getLogger(__name__)
  8. class Context:
  9. """
  10. Context used within the transliteration and passed to hook functions.
  11. """
  12. def __init__(self, src, general, langsec):
  13. """
  14. Initialize a context.
  15. Args:
  16. src (str): The original text. This is meant to never change.
  17. general (dict): general section of the current config.
  18. langsec (dict): Language configuration section being used.
  19. """
  20. self.src = src
  21. self.general = general
  22. self.langsec = langsec
  23. self.dest_ls = []
  24. def transliterate(src, lang, r2s=False):
  25. """
  26. Transliterate a single string.
  27. Args:
  28. src (str): Source string.
  29. lang (str): Language name.
  30. Keyword args:
  31. r2s (bool): If False (the default), the source is considered to be a
  32. non-latin script in the language and script specified, and the output
  33. the Romanization thereof; if True, the source is considered to be
  34. romanized text to be transliterated into the specified script/language.
  35. Return:
  36. str: The transliterated string.
  37. """
  38. source_str = "Latin" if r2s else lang
  39. target_str = lang if r2s else "Latin"
  40. logger.info(f"Transliteration is from {source_str} to {target_str}.")
  41. cfg = load_table(lang)
  42. logger.info(f"Loaded table for {lang}.")
  43. # General directives.
  44. general = cfg.get("general", {})
  45. if not r2s and "script_to_roman" not in cfg:
  46. raise NotImplementedError(
  47. f"Script-to-Roman transliteration not yet supported for {lang}."
  48. )
  49. elif r2s and "roman_to_script" not in cfg:
  50. raise NotImplementedError(
  51. f"Roman-to-script transliteration not yet supported for {lang}."
  52. )
  53. langsec = cfg["script_to_roman"] if not r2s else cfg["roman_to_script"]
  54. langsec_dir = langsec.get("directives", {})
  55. langsec_hooks = langsec.get("hooks", {})
  56. ctx = Context(src, general, langsec)
  57. # This hook may take over the whole transliteration process or delegate it
  58. # to some external process, and return the output string directly.
  59. if _run_hook("post_config", ctx, langsec_hooks) == BREAK:
  60. return getattr(ctx, "dest", "")
  61. # Loop through source characters. The increment of each loop depends on
  62. # the length of the token that eventually matches.
  63. ignore_list = langsec.get("ignore", []) # Only present in R2S
  64. ctx.cur = 0
  65. while ctx.cur < len(src):
  66. # This hook may skip the parsing of the current
  67. # token or exit the scanning loop altogether.
  68. hret = _run_hook("begin_input_token", ctx, langsec_hooks)
  69. if hret == BREAK:
  70. logger.debug("Breaking text scanning from hook signal.")
  71. break
  72. if hret == CONT:
  73. logger.debug("Skipping scanning iteration from hook signal.")
  74. continue
  75. # Check ignore list first. Find as many subsequent ignore tokens
  76. # as possible before moving on to looking for match tokens.
  77. ctx.tk = None
  78. while True:
  79. ctx.ignoring = False
  80. for ctx.tk in ignore_list:
  81. hret = _run_hook("pre_ignore_token", ctx, langsec_hooks)
  82. if hret == BREAK:
  83. break
  84. if hret == CONT:
  85. continue
  86. step = len(ctx.tk)
  87. if ctx.tk == src[ctx.cur:ctx.cur + step]:
  88. # The position matches an ignore token.
  89. hret = _run_hook("on_ignore_match", ctx, langsec_hooks)
  90. if hret == BREAK:
  91. break
  92. if hret == CONT:
  93. continue
  94. logger.info(f"Ignored token: {ctx.tk}")
  95. ctx.dest_ls.append(ctx.tk)
  96. ctx.cur += step
  97. ctx.ignoring = True
  98. break
  99. # We looked through all ignore tokens, not found any. Move on.
  100. if not ctx.ignoring:
  101. break
  102. # Otherwise, if we found a match, check if the next position may be
  103. # ignored as well.
  104. delattr(ctx, "tk")
  105. delattr(ctx, "ignoring")
  106. # Begin transliteration token lookup.
  107. ctx.match = False
  108. for ctx.src_tk, ctx.dest_tk in langsec["map"]:
  109. hret = _run_hook("pre_tx_token", ctx, langsec_hooks)
  110. if hret == BREAK:
  111. break
  112. if hret == CONT:
  113. continue
  114. # Longer tokens should be guaranteed to be scanned before their
  115. # substrings at this point.
  116. step = len(ctx.src_tk)
  117. if ctx.src_tk == src[ctx.cur:ctx.cur + step]:
  118. ctx.match = True
  119. # This hook may skip this token or break out of the token
  120. # lookup for the current position.
  121. hret = _run_hook("on_tx_token_match", ctx, langsec_hooks)
  122. if hret == BREAK:
  123. break
  124. if hret == CONT:
  125. continue
  126. # A match is found. Stop scanning tokens, append result, and
  127. # proceed scanning the source.
  128. ctx.dest_ls.append(ctx.dest_tk)
  129. ctx.cur += step
  130. break
  131. if ctx.match is False:
  132. delattr(ctx, "match")
  133. hret = _run_hook("on_no_tx_token_match", ctx, langsec_hooks)
  134. if hret == BREAK:
  135. break
  136. if hret == CONT:
  137. continue
  138. # No match found. Copy non-mapped character (one at a time).
  139. logger.info(
  140. f"Token {src[ctx.cur]} (\\u{hex(ord(src[ctx.cur]))[2:]})"
  141. f"at position {ctx.cur} is not mapped.")
  142. ctx.dest_ls.append(src[ctx.cur])
  143. ctx.cur += 1
  144. else:
  145. delattr(ctx, "match")
  146. delattr(ctx, "cur")
  147. # This hook may take care of the assembly and cause the function to return
  148. # its own return value.
  149. hret = _run_hook("pre_assembly", ctx, langsec_hooks)
  150. if hret is not None:
  151. return hret
  152. if langsec_dir.get("capitalize", False):
  153. ctx.dest_ls[0] = ctx.dest_ls[0].capitalize()
  154. logger.debug(f"Output list: {ctx.dest_ls}")
  155. ctx.dest = "".join(ctx.dest_ls)
  156. # This hook may reassign the output string and/or cause the function to
  157. # return it immediately.
  158. hret = _run_hook("post_assembly", ctx, langsec_hooks)
  159. if hret == "ret":
  160. return ctx.dest
  161. # Strip multiple spaces and leading/trailing whitespace.
  162. ctx.dest = re.sub(MULTI_WS_RE, ' ', ctx.dest.strip())
  163. return ctx.dest
  164. def _run_hook(hname, ctx, hooks):
  165. ret = None
  166. for hook_def in hooks.get(hname, []):
  167. kwargs = hook_def[1] if len(hook_def) > 1 else {}
  168. ret = hook_def[0](ctx, **kwargs)
  169. if ret in (BREAK, CONT):
  170. # This will stop parsing hooks functions and tell the caller to
  171. # break out of the outer loop or skip iteration.
  172. return ret
  173. return ret