css_minifer.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. # -*- coding: utf-8 -*-
  2. """CSS Minifier functions for CSS-HTML-JS-Minify."""
  3. import re
  4. import itertools
  5. from .variables import EXTENDED_NAMED_COLORS, CSS_PROPS_TEXT
  6. __all__ = ('css_minify', 'condense_semicolons')
  7. def _compile_props(props_text, grouped=False):
  8. """Take a list of props and prepare them."""
  9. props, prefixes = [], "-webkit-,-khtml-,-epub-,-moz-,-ms-,-o-,".split(",")
  10. for propline in props_text.strip().lower().splitlines():
  11. props += [pre + pro for pro in propline.split(" ") for pre in prefixes]
  12. props = filter(lambda line: not line.startswith('#'), props)
  13. if not grouped:
  14. props = list(filter(None, props))
  15. return props, [0]*len(props)
  16. final_props, groups, g_id = [], [], 0
  17. for prop in props:
  18. if prop.strip():
  19. final_props.append(prop)
  20. groups.append(g_id)
  21. else:
  22. g_id += 1
  23. return final_props, groups
  24. def _prioritify(line_of_css, css_props_text_as_list):
  25. """Return args priority, priority is integer and smaller means higher."""
  26. sorted_css_properties, groups_by_alphabetic_order = css_props_text_as_list
  27. priority_integer, group_integer = 9999, 0
  28. for css_property in sorted_css_properties:
  29. if css_property.lower() == line_of_css.split(":")[0].lower().strip():
  30. priority_integer = sorted_css_properties.index(css_property)
  31. group_integer = groups_by_alphabetic_order[priority_integer]
  32. break
  33. return priority_integer, group_integer
  34. def _props_grouper(props, pgs):
  35. """Return groups for properties."""
  36. if not props:
  37. return props
  38. # props = sorted([
  39. # _ if _.strip().endswith(";")
  40. # and not _.strip().endswith("*/") and not _.strip().endswith("/*")
  41. # else _.rstrip() + ";\n" for _ in props])
  42. props_pg = zip(map(lambda prop: _prioritify(prop, pgs), props), props)
  43. props_pg = sorted(props_pg, key=lambda item: item[0][1])
  44. props_by_groups = map(
  45. lambda item: list(item[1]),
  46. itertools.groupby(props_pg, key=lambda item: item[0][1]))
  47. props_by_groups = map(lambda item: sorted(
  48. item, key=lambda item: item[0][0]), props_by_groups)
  49. props = []
  50. for group in props_by_groups:
  51. group = map(lambda item: item[1], group)
  52. props += group
  53. props += ['\n']
  54. props.pop()
  55. return props
  56. def sort_properties(css_unsorted_string):
  57. """CSS Property Sorter Function.
  58. This function will read buffer argument, split it to a list by lines,
  59. sort it by defined rule, and return sorted buffer if it's CSS property.
  60. This function depends on '_prioritify' function.
  61. """
  62. css_pgs = _compile_props(CSS_PROPS_TEXT, grouped=False) # Do Not Group.
  63. pattern = re.compile(r'(.*?{\r?\n?)(.*?)(}.*?)|(.*)',
  64. re.DOTALL + re.MULTILINE)
  65. matched_patterns = pattern.findall(css_unsorted_string)
  66. sorted_patterns, sorted_buffer = [], css_unsorted_string
  67. re_prop = re.compile(r'((?:.*?)(?:;)(?:.*?\n)|(?:.*))',
  68. re.DOTALL + re.MULTILINE)
  69. if len(matched_patterns) != 0:
  70. for matched_groups in matched_patterns:
  71. sorted_patterns += matched_groups[0].splitlines(True)
  72. props = map(lambda line: line.lstrip('\n'),
  73. re_prop.findall(matched_groups[1]))
  74. props = list(filter(lambda line: line.strip('\n '), props))
  75. props = _props_grouper(props, css_pgs)
  76. sorted_patterns += props
  77. sorted_patterns += matched_groups[2].splitlines(True)
  78. sorted_patterns += matched_groups[3].splitlines(True)
  79. sorted_buffer = ''.join(sorted_patterns)
  80. return sorted_buffer
  81. def remove_comments(css):
  82. """Remove all CSS comment blocks."""
  83. iemac, preserve = False, False
  84. comment_start = css.find("/*")
  85. while comment_start >= 0: # Preserve comments that look like `/*!...*/`.
  86. # Slicing is used to make sure we dont get an IndexError.
  87. preserve = css[comment_start + 2:comment_start + 3] == "!"
  88. comment_end = css.find("*/", comment_start + 2)
  89. if comment_end < 0:
  90. if not preserve:
  91. css = css[:comment_start]
  92. break
  93. elif comment_end >= (comment_start + 2):
  94. if css[comment_end - 1] == "\\":
  95. # This is an IE Mac-specific comment; leave this one and the
  96. # following one alone.
  97. comment_start = comment_end + 2
  98. iemac = True
  99. elif iemac:
  100. comment_start = comment_end + 2
  101. iemac = False
  102. elif not preserve:
  103. css = css[:comment_start] + css[comment_end + 2:]
  104. else:
  105. comment_start = comment_end + 2
  106. comment_start = css.find("/*", comment_start)
  107. return css
  108. def remove_unnecessary_whitespace(css):
  109. """Remove unnecessary whitespace characters."""
  110. def pseudoclasscolon(css):
  111. """Prevent 'p :link' from becoming 'p:link'.
  112. Translates 'p :link' into 'p ___PSEUDOCLASSCOLON___link'.
  113. This is translated back again later.
  114. """
  115. regex = re.compile(r"(^|\})(([^\{\:])+\:)+([^\{]*\{)")
  116. match = regex.search(css)
  117. while match:
  118. css = ''.join([
  119. css[:match.start()],
  120. match.group().replace(":", "___PSEUDOCLASSCOLON___"),
  121. css[match.end():]])
  122. match = regex.search(css)
  123. return css
  124. css = pseudoclasscolon(css)
  125. # Remove spaces from before things.
  126. css = re.sub(r"\s+([!{};:>\(\)\],])", r"\1", css)
  127. # If there is a `@charset`, then only allow one, and move to beginning.
  128. css = re.sub(r"^(.*)(@charset \"[^\"]*\";)", r"\2\1", css)
  129. css = re.sub(r"^(\s*@charset [^;]+;\s*)+", r"\1", css)
  130. # Put the space back in for a few cases, such as `@media screen` and
  131. # `(-webkit-min-device-pixel-ratio:0)`.
  132. css = re.sub(r"\band\(", "and (", css)
  133. # Put the colons back.
  134. css = css.replace('___PSEUDOCLASSCOLON___', ':')
  135. # Remove spaces from after things.
  136. css = re.sub(r"([!{}:;>\(\[,])\s+", r"\1", css)
  137. return css
  138. def remove_unnecessary_semicolons(css):
  139. """Remove unnecessary semicolons."""
  140. return re.sub(r";+\}", "}", css)
  141. def remove_empty_rules(css):
  142. """Remove empty rules."""
  143. return re.sub(r"[^\}\{]+\{\}", "", css)
  144. def normalize_rgb_colors_to_hex(css):
  145. """Convert `rgb(51,102,153)` to `#336699`."""
  146. regex = re.compile(r"rgb\s*\(\s*([0-9,\s]+)\s*\)")
  147. match = regex.search(css)
  148. while match:
  149. colors = map(lambda s: s.strip(), match.group(1).split(","))
  150. hexcolor = '#%.2x%.2x%.2x' % tuple(map(int, colors))
  151. css = css.replace(match.group(), hexcolor)
  152. match = regex.search(css)
  153. return css
  154. def condense_zero_units(css):
  155. """Replace `0(px, em, %, etc)` with `0`."""
  156. return re.sub(r"([\s:])(0)(px|em|%|in|q|ch|cm|mm|pc|pt|ex|rem|s|ms|"
  157. r"deg|grad|rad|turn|vw|vh|vmin|vmax|fr)", r"\1\2", css)
  158. def condense_multidimensional_zeros(css):
  159. """Replace `:0 0 0 0;`, `:0 0 0;` etc. with `:0;`."""
  160. return css.replace(":0 0 0 0;", ":0;").replace(
  161. ":0 0 0;", ":0;").replace(":0 0;", ":0;").replace(
  162. "background-position:0;", "background-position:0 0;").replace(
  163. "transform-origin:0;", "transform-origin:0 0;")
  164. def condense_floating_points(css):
  165. """Replace `0.6` with `.6` where possible."""
  166. return re.sub(r"(:|\s)0+\.(\d+)", r"\1.\2", css)
  167. def condense_hex_colors(css):
  168. """Shorten colors from #AABBCC to #ABC where possible."""
  169. regex = re.compile(
  170. r"""([^\"'=\s])(\s*)#([0-9a-f])([0-9a-f])([0-9a-f])"""
  171. r"""([0-9a-f])([0-9a-f])([0-9a-f])""", re.I | re.S)
  172. match = regex.search(css)
  173. while match:
  174. first = match.group(3) + match.group(5) + match.group(7)
  175. second = match.group(4) + match.group(6) + match.group(8)
  176. if first.lower() == second.lower():
  177. css = css.replace(
  178. match.group(), match.group(1) + match.group(2) + '#' + first)
  179. match = regex.search(css, match.end() - 3)
  180. else:
  181. match = regex.search(css, match.end())
  182. return css
  183. def condense_whitespace(css):
  184. """Condense multiple adjacent whitespace characters into one."""
  185. return re.sub(r"\s+", " ", css)
  186. def condense_semicolons(css):
  187. """Condense multiple adjacent semicolon characters into one."""
  188. return re.sub(r";;+", ";", css)
  189. def wrap_css_lines(css, line_length=80):
  190. """Wrap the lines of the given CSS to an approximate length."""
  191. lines, line_start = [], 0
  192. for i, char in enumerate(css):
  193. # Its safe to break after } characters.
  194. if char == '}' and (i - line_start >= line_length):
  195. lines.append(css[line_start:i + 1])
  196. line_start = i + 1
  197. if line_start < len(css):
  198. lines.append(css[line_start:])
  199. return '\n'.join(lines)
  200. def condense_font_weight(css):
  201. """Condense multiple font weights into shorter integer equals."""
  202. return css.replace('font-weight:normal;', 'font-weight:400;').replace(
  203. 'font-weight:bold;', 'font-weight:700;')
  204. def condense_std_named_colors(css):
  205. """Condense named color values to shorter replacement using HEX."""
  206. for color_name, color_hexa in iter(tuple({
  207. ':aqua;': ':#0ff;', ':blue;': ':#00f;',
  208. ':fuchsia;': ':#f0f;', ':yellow;': ':#ff0;'}.items())):
  209. css = css.replace(color_name, color_hexa)
  210. return css
  211. def condense_xtra_named_colors(css):
  212. """Condense named color values to shorter replacement using HEX."""
  213. for k, v in iter(tuple(EXTENDED_NAMED_COLORS.items())):
  214. same_color_but_rgb = 'rgb({0},{1},{2})'.format(v[0], v[1], v[2])
  215. if len(k) > len(same_color_but_rgb):
  216. css = css.replace(k, same_color_but_rgb)
  217. return css
  218. def remove_url_quotes(css):
  219. """Fix for url() does not need quotes."""
  220. return re.sub(r'url\((["\'])([^)]*)\1\)', r'url(\2)', css)
  221. def condense_border_none(css):
  222. """Condense border:none; to border:0;."""
  223. return css.replace("border:none;", "border:0;")
  224. def add_encoding(css):
  225. """Add @charset 'UTF-8'; if missing."""
  226. return '@charset "utf-8";' + css if "@charset" not in css.lower() else css
  227. def restore_needed_space(css):
  228. """Fix CSS for some specific cases where a white space is needed."""
  229. return css.replace("!important", " !important").replace( # !important
  230. "@media(", "@media (").replace( # media queries # jpeg > jpg
  231. "data:image/jpeg;base64,", "data:image/jpg;base64,").rstrip("\n;")
  232. def unquote_selectors(css):
  233. """Fix CSS for some specific selectors where Quotes is not needed."""
  234. return re.compile('([a-zA-Z]+)="([a-zA-Z0-9-_\.]+)"]').sub(r'\1=\2]', css)
  235. def css_minify(css, wrap=False, comments=False, sort=False, noprefix=False):
  236. """Minify CSS main function."""
  237. css = remove_comments(css) if not comments else css
  238. css = sort_properties(css) if sort else css
  239. css = unquote_selectors(css)
  240. css = condense_whitespace(css)
  241. css = remove_url_quotes(css)
  242. css = condense_xtra_named_colors(css)
  243. css = condense_font_weight(css)
  244. css = remove_unnecessary_whitespace(css)
  245. css = condense_std_named_colors(css)
  246. css = remove_unnecessary_semicolons(css)
  247. css = condense_zero_units(css)
  248. css = condense_multidimensional_zeros(css)
  249. css = condense_floating_points(css)
  250. css = normalize_rgb_colors_to_hex(css)
  251. css = condense_hex_colors(css)
  252. css = condense_border_none(css)
  253. css = wrap_css_lines(css, 80) if wrap else css
  254. css = condense_semicolons(css)
  255. css = add_encoding(css) if not noprefix else css
  256. css = restore_needed_space(css)
  257. return css.strip()