summaryrefslogtreecommitdiff
path: root/plugins/compressor/css_minifer.py
diff options
context:
space:
mode:
authorsrv <enmanuel.saravia.externo@pandero.com.pe>2025-04-28 17:11:28 -0500
committersrv <enmanuel.saravia.externo@pandero.com.pe>2025-04-28 17:11:28 -0500
commitf35a7b0e70032de2feec9f3bda09da44cf0e1073 (patch)
tree1e0e09581dd3707d0ceb93346452dd14451a8423 /plugins/compressor/css_minifer.py
first commit
Diffstat (limited to 'plugins/compressor/css_minifer.py')
-rw-r--r--plugins/compressor/css_minifer.py316
1 files changed, 316 insertions, 0 deletions
diff --git a/plugins/compressor/css_minifer.py b/plugins/compressor/css_minifer.py
new file mode 100644
index 0000000..52a30e9
--- /dev/null
+++ b/plugins/compressor/css_minifer.py
@@ -0,0 +1,316 @@
+# -*- coding: utf-8 -*-
+
+"""CSS Minifier functions for CSS-HTML-JS-Minify."""
+
+import re
+import itertools
+
+from .variables import EXTENDED_NAMED_COLORS, CSS_PROPS_TEXT
+
+
+__all__ = ('css_minify', 'condense_semicolons')
+
+
+def _compile_props(props_text, grouped=False):
+ """Take a list of props and prepare them."""
+ props, prefixes = [], "-webkit-,-khtml-,-epub-,-moz-,-ms-,-o-,".split(",")
+ for propline in props_text.strip().lower().splitlines():
+ props += [pre + pro for pro in propline.split(" ") for pre in prefixes]
+ props = filter(lambda line: not line.startswith('#'), props)
+ if not grouped:
+ props = list(filter(None, props))
+ return props, [0]*len(props)
+ final_props, groups, g_id = [], [], 0
+ for prop in props:
+ if prop.strip():
+ final_props.append(prop)
+ groups.append(g_id)
+ else:
+ g_id += 1
+ return final_props, groups
+
+
+def _prioritify(line_of_css, css_props_text_as_list):
+ """Return args priority, priority is integer and smaller means higher."""
+ sorted_css_properties, groups_by_alphabetic_order = css_props_text_as_list
+ priority_integer, group_integer = 9999, 0
+ for css_property in sorted_css_properties:
+ if css_property.lower() == line_of_css.split(":")[0].lower().strip():
+ priority_integer = sorted_css_properties.index(css_property)
+ group_integer = groups_by_alphabetic_order[priority_integer]
+ break
+ return priority_integer, group_integer
+
+
+def _props_grouper(props, pgs):
+ """Return groups for properties."""
+ if not props:
+ return props
+ # props = sorted([
+ # _ if _.strip().endswith(";")
+ # and not _.strip().endswith("*/") and not _.strip().endswith("/*")
+ # else _.rstrip() + ";\n" for _ in props])
+ props_pg = zip(map(lambda prop: _prioritify(prop, pgs), props), props)
+ props_pg = sorted(props_pg, key=lambda item: item[0][1])
+ props_by_groups = map(
+ lambda item: list(item[1]),
+ itertools.groupby(props_pg, key=lambda item: item[0][1]))
+ props_by_groups = map(lambda item: sorted(
+ item, key=lambda item: item[0][0]), props_by_groups)
+ props = []
+ for group in props_by_groups:
+ group = map(lambda item: item[1], group)
+ props += group
+ props += ['\n']
+ props.pop()
+ return props
+
+
+def sort_properties(css_unsorted_string):
+ """CSS Property Sorter Function.
+
+ This function will read buffer argument, split it to a list by lines,
+ sort it by defined rule, and return sorted buffer if it's CSS property.
+ This function depends on '_prioritify' function.
+ """
+ css_pgs = _compile_props(CSS_PROPS_TEXT, grouped=False) # Do Not Group.
+ pattern = re.compile(r'(.*?{\r?\n?)(.*?)(}.*?)|(.*)',
+ re.DOTALL + re.MULTILINE)
+ matched_patterns = pattern.findall(css_unsorted_string)
+ sorted_patterns, sorted_buffer = [], css_unsorted_string
+ re_prop = re.compile(r'((?:.*?)(?:;)(?:.*?\n)|(?:.*))',
+ re.DOTALL + re.MULTILINE)
+ if len(matched_patterns) != 0:
+ for matched_groups in matched_patterns:
+ sorted_patterns += matched_groups[0].splitlines(True)
+ props = map(lambda line: line.lstrip('\n'),
+ re_prop.findall(matched_groups[1]))
+ props = list(filter(lambda line: line.strip('\n '), props))
+ props = _props_grouper(props, css_pgs)
+ sorted_patterns += props
+ sorted_patterns += matched_groups[2].splitlines(True)
+ sorted_patterns += matched_groups[3].splitlines(True)
+ sorted_buffer = ''.join(sorted_patterns)
+ return sorted_buffer
+
+
+def remove_comments(css):
+ """Remove all CSS comment blocks."""
+ iemac, preserve = False, False
+ comment_start = css.find("/*")
+ while comment_start >= 0: # Preserve comments that look like `/*!...*/`.
+ # Slicing is used to make sure we dont get an IndexError.
+ preserve = css[comment_start + 2:comment_start + 3] == "!"
+ comment_end = css.find("*/", comment_start + 2)
+ if comment_end < 0:
+ if not preserve:
+ css = css[:comment_start]
+ break
+ elif comment_end >= (comment_start + 2):
+ if css[comment_end - 1] == "\\":
+ # This is an IE Mac-specific comment; leave this one and the
+ # following one alone.
+ comment_start = comment_end + 2
+ iemac = True
+ elif iemac:
+ comment_start = comment_end + 2
+ iemac = False
+ elif not preserve:
+ css = css[:comment_start] + css[comment_end + 2:]
+ else:
+ comment_start = comment_end + 2
+ comment_start = css.find("/*", comment_start)
+ return css
+
+
+def remove_unnecessary_whitespace(css):
+ """Remove unnecessary whitespace characters."""
+
+ def pseudoclasscolon(css):
+ """Prevent 'p :link' from becoming 'p:link'.
+
+ Translates 'p :link' into 'p ___PSEUDOCLASSCOLON___link'.
+ This is translated back again later.
+ """
+ regex = re.compile(r"(^|\})(([^\{\:])+\:)+([^\{]*\{)")
+ match = regex.search(css)
+ while match:
+ css = ''.join([
+ css[:match.start()],
+ match.group().replace(":", "___PSEUDOCLASSCOLON___"),
+ css[match.end():]])
+ match = regex.search(css)
+ return css
+
+ css = pseudoclasscolon(css)
+ # Remove spaces from before things.
+ css = re.sub(r"\s+([!{};:>\(\)\],])", r"\1", css)
+ # If there is a `@charset`, then only allow one, and move to beginning.
+ css = re.sub(r"^(.*)(@charset \"[^\"]*\";)", r"\2\1", css)
+ css = re.sub(r"^(\s*@charset [^;]+;\s*)+", r"\1", css)
+ # Put the space back in for a few cases, such as `@media screen` and
+ # `(-webkit-min-device-pixel-ratio:0)`.
+ css = re.sub(r"\band\(", "and (", css)
+ # Put the colons back.
+ css = css.replace('___PSEUDOCLASSCOLON___', ':')
+ # Remove spaces from after things.
+ css = re.sub(r"([!{}:;>\(\[,])\s+", r"\1", css)
+ return css
+
+
+def remove_unnecessary_semicolons(css):
+ """Remove unnecessary semicolons."""
+ return re.sub(r";+\}", "}", css)
+
+
+def remove_empty_rules(css):
+ """Remove empty rules."""
+ return re.sub(r"[^\}\{]+\{\}", "", css)
+
+
+def normalize_rgb_colors_to_hex(css):
+ """Convert `rgb(51,102,153)` to `#336699`."""
+ regex = re.compile(r"rgb\s*\(\s*([0-9,\s]+)\s*\)")
+ match = regex.search(css)
+ while match:
+ colors = map(lambda s: s.strip(), match.group(1).split(","))
+ hexcolor = '#%.2x%.2x%.2x' % tuple(map(int, colors))
+ css = css.replace(match.group(), hexcolor)
+ match = regex.search(css)
+ return css
+
+
+def condense_zero_units(css):
+ """Replace `0(px, em, %, etc)` with `0`."""
+ return re.sub(r"([\s:])(0)(px|em|%|in|q|ch|cm|mm|pc|pt|ex|rem|s|ms|"
+ r"deg|grad|rad|turn|vw|vh|vmin|vmax|fr)", r"\1\2", css)
+
+
+def condense_multidimensional_zeros(css):
+ """Replace `:0 0 0 0;`, `:0 0 0;` etc. with `:0;`."""
+ return css.replace(":0 0 0 0;", ":0;").replace(
+ ":0 0 0;", ":0;").replace(":0 0;", ":0;").replace(
+ "background-position:0;", "background-position:0 0;").replace(
+ "transform-origin:0;", "transform-origin:0 0;")
+
+
+def condense_floating_points(css):
+ """Replace `0.6` with `.6` where possible."""
+ return re.sub(r"(:|\s)0+\.(\d+)", r"\1.\2", css)
+
+
+def condense_hex_colors(css):
+ """Shorten colors from #AABBCC to #ABC where possible."""
+ regex = re.compile(
+ r"""([^\"'=\s])(\s*)#([0-9a-f])([0-9a-f])([0-9a-f])"""
+ r"""([0-9a-f])([0-9a-f])([0-9a-f])""", re.I | re.S)
+ match = regex.search(css)
+ while match:
+ first = match.group(3) + match.group(5) + match.group(7)
+ second = match.group(4) + match.group(6) + match.group(8)
+ if first.lower() == second.lower():
+ css = css.replace(
+ match.group(), match.group(1) + match.group(2) + '#' + first)
+ match = regex.search(css, match.end() - 3)
+ else:
+ match = regex.search(css, match.end())
+ return css
+
+
+def condense_whitespace(css):
+ """Condense multiple adjacent whitespace characters into one."""
+ return re.sub(r"\s+", " ", css)
+
+
+def condense_semicolons(css):
+ """Condense multiple adjacent semicolon characters into one."""
+ return re.sub(r";;+", ";", css)
+
+
+def wrap_css_lines(css, line_length=80):
+ """Wrap the lines of the given CSS to an approximate length."""
+ lines, line_start = [], 0
+ for i, char in enumerate(css):
+ # Its safe to break after } characters.
+ if char == '}' and (i - line_start >= line_length):
+ lines.append(css[line_start:i + 1])
+ line_start = i + 1
+ if line_start < len(css):
+ lines.append(css[line_start:])
+ return '\n'.join(lines)
+
+
+def condense_font_weight(css):
+ """Condense multiple font weights into shorter integer equals."""
+ return css.replace('font-weight:normal;', 'font-weight:400;').replace(
+ 'font-weight:bold;', 'font-weight:700;')
+
+
+def condense_std_named_colors(css):
+ """Condense named color values to shorter replacement using HEX."""
+ for color_name, color_hexa in iter(tuple({
+ ':aqua;': ':#0ff;', ':blue;': ':#00f;',
+ ':fuchsia;': ':#f0f;', ':yellow;': ':#ff0;'}.items())):
+ css = css.replace(color_name, color_hexa)
+ return css
+
+
+def condense_xtra_named_colors(css):
+ """Condense named color values to shorter replacement using HEX."""
+ for k, v in iter(tuple(EXTENDED_NAMED_COLORS.items())):
+ same_color_but_rgb = 'rgb({0},{1},{2})'.format(v[0], v[1], v[2])
+ if len(k) > len(same_color_but_rgb):
+ css = css.replace(k, same_color_but_rgb)
+ return css
+
+
+def remove_url_quotes(css):
+ """Fix for url() does not need quotes."""
+ return re.sub(r'url\((["\'])([^)]*)\1\)', r'url(\2)', css)
+
+
+def condense_border_none(css):
+ """Condense border:none; to border:0;."""
+ return css.replace("border:none;", "border:0;")
+
+
+def add_encoding(css):
+ """Add @charset 'UTF-8'; if missing."""
+ return '@charset "utf-8";' + css if "@charset" not in css.lower() else css
+
+
+def restore_needed_space(css):
+ """Fix CSS for some specific cases where a white space is needed."""
+ return css.replace("!important", " !important").replace( # !important
+ "@media(", "@media (").replace( # media queries # jpeg > jpg
+ "data:image/jpeg;base64,", "data:image/jpg;base64,").rstrip("\n;")
+
+
+def unquote_selectors(css):
+ """Fix CSS for some specific selectors where Quotes is not needed."""
+ return re.compile('([a-zA-Z]+)="([a-zA-Z0-9-_\.]+)"]').sub(r'\1=\2]', css)
+
+
+def css_minify(css, wrap=False, comments=False, sort=False, noprefix=False):
+ """Minify CSS main function."""
+ css = remove_comments(css) if not comments else css
+ css = sort_properties(css) if sort else css
+ css = unquote_selectors(css)
+ css = condense_whitespace(css)
+ css = remove_url_quotes(css)
+ css = condense_xtra_named_colors(css)
+ css = condense_font_weight(css)
+ css = remove_unnecessary_whitespace(css)
+ css = condense_std_named_colors(css)
+ css = remove_unnecessary_semicolons(css)
+ css = condense_zero_units(css)
+ css = condense_multidimensional_zeros(css)
+ css = condense_floating_points(css)
+ css = normalize_rgb_colors_to_hex(css)
+ css = condense_hex_colors(css)
+ css = condense_border_none(css)
+ css = wrap_css_lines(css, 80) if wrap else css
+ css = condense_semicolons(css)
+ css = add_encoding(css) if not noprefix else css
+ css = restore_needed_space(css)
+ return css.strip()