typo.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. # -*- coding: utf-8 -*-
  2. # -*- test-case-name: pytils.test.test_typo -*-
  3. """
  4. Russian typography
  5. """
  6. import os
  7. import re
  8. def _sub_patterns(patterns, text):
  9. """
  10. Apply re.sub to bunch of (pattern, repl)
  11. """
  12. for pattern, repl in patterns:
  13. text = re.sub(pattern, repl, text)
  14. return text
  15. # ---------- rules -------------
  16. # rules is a regular function,
  17. # name convention is rl_RULENAME
  18. def rl_testrule(x):
  19. """
  20. Rule for tests. Do nothing.
  21. """
  22. return x
  23. def rl_cleanspaces(x):
  24. """
  25. Clean double spaces, trailing spaces, heading spaces,
  26. spaces before punctuations
  27. """
  28. patterns = (
  29. # arguments for re.sub: pattern and repl
  30. # удаляем пробел перед знаками препинания
  31. (r' +([\.,?!\)]+)', r'\1'),
  32. # добавляем пробел после знака препинания, если только за ним нет другого
  33. (r'([\.,?!\)]+)([^\.!,?\)]+)', r'\1 \2'),
  34. # убираем пробел после открывающей скобки
  35. (r'(\S+)\s*(\()\s*(\S+)', r'\1 (\3'),
  36. )
  37. # удаляем двойные, начальные и конечные пробелы
  38. return os.linesep.join(
  39. ' '.join(part for part in line.split(' ') if part)
  40. for line in _sub_patterns(patterns, x).split(os.linesep)
  41. )
  42. def rl_ellipsis(x):
  43. """
  44. Replace three dots to ellipsis
  45. """
  46. patterns = (
  47. # если больше трех точек, то не заменяем на троеточие
  48. # чтобы не было глупых .....->…..
  49. (r'([^\.]|^)\.\.\.([^\.]|$)', '\\1\u2026\\2'),
  50. # если троеточие в начале строки или возле кавычки --
  51. # это цитата, пробел между троеточием и первым
  52. # словом нужно убрать
  53. (re.compile('(^|\\"|\u201c|\xab)\\s*\u2026\\s*([А-Яа-яA-Za-z])', re.UNICODE), '\\1\u2026\\2'),
  54. )
  55. return _sub_patterns(patterns, x)
  56. def rl_initials(x):
  57. """
  58. Replace space between initials and surname by thin space
  59. """
  60. return re.sub(
  61. re.compile('([А-Я])\\.\\s*([А-Я])\\.\\s*([А-Я][а-я]+)', re.UNICODE),
  62. '\\1.\\2.\u2009\\3',
  63. x
  64. )
  65. def rl_dashes(x):
  66. """
  67. Replace dash to long/medium dashes
  68. """
  69. patterns = (
  70. # тире
  71. (re.compile('(^|(.\\s))\\-\\-?(([\\s\u202f].)|$)', re.MULTILINE|re.UNICODE), '\\1\u2014\\3'),
  72. # диапазоны между цифрами - en dash
  73. (re.compile('(\\d[\\s\u2009]*)\\-([\\s\u2009]*\d)', re.MULTILINE|re.UNICODE), '\\1\u2013\\2'),
  74. # TODO: а что с минусом?
  75. )
  76. return _sub_patterns(patterns, x)
  77. def rl_wordglue(x):
  78. """
  79. Glue (set nonbreakable space) short words with word before/after
  80. """
  81. patterns = (
  82. # частицы склеиваем с предыдущим словом
  83. (re.compile('(\\s+)(же|ли|ль|бы|б|ж|ка)([\\.,!\\?:;]?\\s+)', re.UNICODE), '\u202f\\2\\3'),
  84. # склеиваем короткие слова со следующим словом
  85. (re.compile('\\b([a-zA-ZА-Яа-я]{1,3})(\\s+)', re.UNICODE), '\\1\u202f'),
  86. # склеиваем тире с предыдущим словом
  87. (re.compile('(\\s+)([\u2014\\-]+)(\\s+)', re.UNICODE), '\u202f\\2\\3'),
  88. # склеиваем два последних слова в абзаце между собой
  89. # полагается, что абзацы будут передаваться отдельной строкой
  90. (re.compile('([^\\s]+)\\s+([^\\s]+)$', re.UNICODE), '\\1\u202f\\2'),
  91. )
  92. return _sub_patterns(patterns, x)
  93. def rl_marks(x):
  94. """
  95. Replace +-, (c), (tm), (r), (p), etc by its typographic eqivalents
  96. """
  97. # простые замены, можно без регулярок
  98. replacements = (
  99. ('(r)', '\u00ae'), # ®
  100. ('(R)', '\u00ae'), # ®
  101. ('(p)', '\u00a7'), # §
  102. ('(P)', '\u00a7'), # §
  103. ('(tm)', '\u2122'), # ™
  104. ('(TM)', '\u2122'), # ™
  105. )
  106. patterns = (
  107. # копирайт ставится до года: © 2008 Юрий Юревич
  108. (re.compile('\\([cCсС]\\)\\s*(\\d+)', re.UNICODE), '\u00a9\u202f\\1'),
  109. (r'([^+])(\+\-|\-\+)', '\\1\u00b1'), # ±
  110. # градусы с минусом
  111. ('\\-(\\d+)[\\s]*([FCС][^\\w])', '\u2212\\1\202f\u00b0\\2'), # −12 °C, −53 °F
  112. # градусы без минуса
  113. ('(\\d+)[\\s]*([FCС][^\\w])', '\\1\u202f\u00b0\\2'), # 12 °C, 53 °F
  114. # ® и ™ приклеиваются к предыдущему слову, без пробела
  115. (re.compile('([A-Za-zА-Яа-я\\!\\?])\\s*(\xae|\u2122)', re.UNICODE), '\\1\\2'),
  116. # No5 -> № 5
  117. (re.compile('(\\s)(No|no|NO|\u2116)[\\s\u2009]*(\\d+)', re.UNICODE), '\\1\u2116\u2009\\3'),
  118. )
  119. for what, to in replacements:
  120. x = x.replace(what, to)
  121. return _sub_patterns(patterns, x)
  122. def rl_quotes(x):
  123. """
  124. Replace quotes by typographic quotes
  125. """
  126. patterns = (
  127. # открывающие кавычки ставятся обычно вплотную к слову слева
  128. # а закрывающие -- вплотную справа
  129. # открывающие русские кавычки-ёлочки
  130. (re.compile(r'((?:^|\s))(")((?u))', re.UNICODE), '\\1\xab\\3'),
  131. # закрывающие русские кавычки-ёлочки
  132. (re.compile(r'(\S)(")((?u))', re.UNICODE), '\\1\xbb\\3'),
  133. # открывающие кавычки-лапки, вместо одинарных кавычек
  134. (re.compile(r'((?:^|\s))(\')((?u))', re.UNICODE), '\\1\u201c\\3'),
  135. # закрывающие кавычки-лапки
  136. (re.compile(r'(\S)(\')((?u))', re.UNICODE), '\\1\u201d\\3'),
  137. )
  138. return _sub_patterns(patterns, x)
  139. # -------- rules end ----------
  140. STANDARD_RULES = ('cleanspaces', 'ellipsis', 'initials', 'marks', 'dashes', 'wordglue', 'quotes')
  141. def _get_rule_by_name(name):
  142. rule = globals().get('rl_%s' % name)
  143. if rule is None:
  144. raise ValueError("Rule %s is not found" % name)
  145. if not callable(rule):
  146. raise ValueError("Rule with name %s is not callable" % name)
  147. return rule
  148. def _resolve_rule_name(rule_or_name, forced_name=None):
  149. if isinstance(rule_or_name, str):
  150. # got name
  151. name = rule_or_name
  152. rule = _get_rule_by_name(name)
  153. elif callable(rule_or_name):
  154. # got rule
  155. name = rule_or_name.__name__
  156. if name.startswith('rl_'):
  157. # by rule name convention
  158. # rule is a function with name rl_RULENAME
  159. name = name[3:]
  160. rule = rule_or_name
  161. else:
  162. raise ValueError(
  163. "Cannot resolve %r: neither rule, nor name" %
  164. rule_or_name)
  165. if forced_name is not None:
  166. name = forced_name
  167. return name, rule
  168. class Typography(object):
  169. """
  170. Russian typography rules applier
  171. """
  172. def __init__(self, *args, **kwargs):
  173. """
  174. Typography applier constructor:
  175. possible variations of constructing rules chain:
  176. rules by it's names:
  177. Typography('first_rule', 'second_rule')
  178. rules callables as is:
  179. Typography(cb_first_rule, cb_second_rule)
  180. mixed:
  181. Typography('first_rule', cb_second_rule)
  182. as list:
  183. Typography(['first_rule', cb_second_rule])
  184. as keyword args:
  185. Typography(rule_name='first_rule',
  186. another_rule=cb_second_rule)
  187. as dict (order of rule execution is not the same):
  188. Typography({'rule name': 'first_rule',
  189. 'another_rule': cb_second_rule})
  190. For standard rules it is recommended to use list of rules
  191. names.
  192. Typography(['first_rule', 'second_rule'])
  193. For custom rules which are named functions,
  194. it is recommended to use list of callables:
  195. Typography([cb_first_rule, cb_second_rule])
  196. For custom rules which are lambda-functions,
  197. it is recommended to use dict:
  198. Typography({'rule_name': lambda x: x})
  199. I.e. the recommended usage is:
  200. Typography(['standard_rule_1', 'standard_rule_2'],
  201. [cb_custom_rule1, cb_custom_rule_2],
  202. {'custom_lambda_rule': lambda x: x})
  203. """
  204. self.rules = {}
  205. self.rules_names = []
  206. # first of all, expand args-lists and args-dicts
  207. expanded_args = []
  208. expanded_kwargs = {}
  209. for arg in args:
  210. if isinstance(arg, (tuple, list)):
  211. expanded_args += list(arg)
  212. elif isinstance(arg, dict):
  213. expanded_kwargs.update(arg)
  214. elif isinstance(arg, str) or callable(arg):
  215. expanded_args.append(arg)
  216. else:
  217. raise TypeError(
  218. "Cannot expand arg %r, must be tuple, list,"\
  219. " dict, str or callable, not" %
  220. (arg, type(arg).__name__))
  221. for kw, arg in kwargs.items():
  222. if isinstance(arg, str) or callable(arg):
  223. expanded_kwargs[kw] = arg
  224. else:
  225. raise TypeError(
  226. "Cannot expand kwarg %r, must be str or "\
  227. "callable, not" % (arg, type(arg).__name__))
  228. # next, resolve rule names to callables
  229. for name, rule in (_resolve_rule_name(a) for a in expanded_args):
  230. self.rules[name] = rule
  231. self.rules_names.append(name)
  232. for name, rule in (_resolve_rule_name(a, k) for k, a in expanded_kwargs.items()):
  233. self.rules[name] = rule
  234. self.rules_names.append(name)
  235. def apply_single_rule(self, rulename, text):
  236. if rulename not in self.rules:
  237. raise ValueError("Rule %s is not found in active rules" % rulename)
  238. try:
  239. res = self.rules[rulename](text)
  240. except ValueError as e:
  241. raise ValueError("Rule %s failed to apply: %s" % (rulename, e))
  242. return res
  243. def apply(self, text):
  244. for rule in self.rules_names:
  245. text = self.apply_single_rule(rule, text)
  246. return text
  247. def __call__(self, text):
  248. return self.apply(text)
  249. def typography(text):
  250. t = Typography(STANDARD_RULES)
  251. return t.apply(text)
  252. if __name__ == '__main__':
  253. from pytils.test import run_tests_from_module, test_typo
  254. run_tests_from_module(test_typo, verbosity=2)