• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3
4"""`cssmin` - A Python port of the YUI CSS compressor.
5
6:Copyright:
7
8 Copyright 2011 - 2014
9 Andr\xe9 Malo or his licensors, as applicable
10
11:License:
12
13 Licensed under the Apache License, Version 2.0 (the "License");
14 you may not use this file except in compliance with the License.
15 You may obtain a copy of the License at
16
17     http://www.apache.org/licenses/LICENSE-2.0
18
19 Unless required by applicable law or agreed to in writing, software
20 distributed under the License is distributed on an "AS IS" BASIS,
21 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 See the License for the specific language governing permissions and
23 limitations under the License.
24
25"""
26
27try:
28    from StringIO import StringIO # The pure-Python StringIO supports unicode.
29except ImportError:
30    from io import StringIO
31import re
32
33
34__version__ = '0.2.0'
35
36
37def remove_comments(css):
38    """Remove all CSS comment blocks."""
39
40    iemac = False
41    preserve = False
42    comment_start = css.find("/*")
43    while comment_start >= 0:
44        # Preserve comments that look like `/*!...*/`.
45        # Slicing is used to make sure we don"t get an IndexError.
46        preserve = css[comment_start + 2:comment_start + 3] == "!"
47
48        comment_end = css.find("*/", comment_start + 2)
49        if comment_end < 0:
50            if not preserve:
51                css = css[:comment_start]
52                break
53        elif comment_end >= (comment_start + 2):
54            if css[comment_end - 1] == "\\":
55                # This is an IE Mac-specific comment; leave this one and the
56                # following one alone.
57                comment_start = comment_end + 2
58                iemac = True
59            elif iemac:
60                comment_start = comment_end + 2
61                iemac = False
62            elif not preserve:
63                css = css[:comment_start] + css[comment_end + 2:]
64            else:
65                comment_start = comment_end + 2
66        comment_start = css.find("/*", comment_start)
67
68    return css
69
70
71def remove_unnecessary_whitespace(css):
72    """Remove unnecessary whitespace characters."""
73
74    def pseudoclasscolon(css):
75
76        """
77        Prevents 'p :link' from becoming 'p:link'.
78
79        Translates 'p :link' into 'p ___PSEUDOCLASSCOLON___link'; this is
80        translated back again later.
81        """
82
83        regex = re.compile(r"(^|\})(([^\{\:])+\:)+([^\{]*\{)")
84        match = regex.search(css)
85        while match:
86            css = ''.join([
87                css[:match.start()],
88                match.group().replace(":", "___PSEUDOCLASSCOLON___"),
89                css[match.end():]])
90            match = regex.search(css)
91        return css
92
93    css = pseudoclasscolon(css)
94    # Remove spaces from before things.
95    css = re.sub(r"\s+([!{};:>+\(\)\],])", r"\1", css)
96
97    # If there is a `@charset`, then only allow one, and move to the beginning.
98    css = re.sub(r"^(.*)(@charset \"[^\"]*\";)", r"\2\1", css)
99    css = re.sub(r"^(\s*@charset [^;]+;\s*)+", r"\1", css)
100
101    # Put the space back in for a few cases, such as `@media screen` and
102    # `(-webkit-min-device-pixel-ratio:0)`.
103    css = re.sub(r"\band\(", "and (", css)
104
105    # Put the colons back.
106    css = css.replace('___PSEUDOCLASSCOLON___', ':')
107
108    # Remove spaces from after things.
109    css = re.sub(r"([!{}:;>+\(\[,])\s+", r"\1", css)
110
111    return css
112
113
114def remove_unnecessary_semicolons(css):
115    """Remove unnecessary semicolons."""
116
117    return re.sub(r";+\}", "}", css)
118
119
120def remove_empty_rules(css):
121    """Remove empty rules."""
122
123    return re.sub(r"[^\}\{]+\{\}", "", css)
124
125
126def normalize_rgb_colors_to_hex(css):
127    """Convert `rgb(51,102,153)` to `#336699`."""
128
129    regex = re.compile(r"rgb\s*\(\s*([0-9,\s]+)\s*\)")
130    match = regex.search(css)
131    while match:
132        colors = map(lambda s: s.strip(), match.group(1).split(","))
133        hexcolor = '#%.2x%.2x%.2x' % tuple(map(int, colors))
134        css = css.replace(match.group(), hexcolor)
135        match = regex.search(css)
136    return css
137
138
139def condense_zero_units(css):
140    """Replace `0(px, em, %, etc)` with `0`."""
141
142    return re.sub(r"([\s:])(0)(px|em|%|in|cm|mm|pc|pt|ex)", r"\1\2", css)
143
144
145def condense_multidimensional_zeros(css):
146    """Replace `:0 0 0 0;`, `:0 0 0;` etc. with `:0;`."""
147
148    css = css.replace(":0 0 0 0;", ":0;")
149    css = css.replace(":0 0 0;", ":0;")
150    css = css.replace(":0 0;", ":0;")
151
152    # Revert `background-position:0;` to the valid `background-position:0 0;`.
153    css = css.replace("background-position:0;", "background-position:0 0;")
154
155    return css
156
157
158def condense_floating_points(css):
159    """Replace `0.6` with `.6` where possible."""
160
161    return re.sub(r"(:|\s)0+\.(\d+)", r"\1.\2", css)
162
163
164def condense_hex_colors(css):
165    """Shorten colors from #AABBCC to #ABC where possible."""
166
167    regex = re.compile(r"([^\"'=\s])(\s*)#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])")
168    match = regex.search(css)
169    while match:
170        first = match.group(3) + match.group(5) + match.group(7)
171        second = match.group(4) + match.group(6) + match.group(8)
172        if first.lower() == second.lower():
173            css = css.replace(match.group(), match.group(1) + match.group(2) + '#' + first)
174            match = regex.search(css, match.end() - 3)
175        else:
176            match = regex.search(css, match.end())
177    return css
178
179
180def condense_whitespace(css):
181    """Condense multiple adjacent whitespace characters into one."""
182
183    return re.sub(r"\s+", " ", css)
184
185
186def condense_semicolons(css):
187    """Condense multiple adjacent semicolon characters into one."""
188
189    return re.sub(r";;+", ";", css)
190
191
192def wrap_css_lines(css, line_length):
193    """Wrap the lines of the given CSS to an approximate length."""
194
195    lines = []
196    line_start = 0
197    for i, char in enumerate(css):
198        # It's safe to break after `}` characters.
199        if char == '}' and (i - line_start >= line_length):
200            lines.append(css[line_start:i + 1])
201            line_start = i + 1
202
203    if line_start < len(css):
204        lines.append(css[line_start:])
205    return '\n'.join(lines)
206
207
208def cssmin(css, wrap=None):
209    css = remove_comments(css)
210    css = condense_whitespace(css)
211    # A pseudo class for the Box Model Hack
212    # (see http://tantek.com/CSS/Examples/boxmodelhack.html)
213    css = css.replace('"\\"}\\""', "___PSEUDOCLASSBMH___")
214    css = remove_unnecessary_whitespace(css)
215    css = remove_unnecessary_semicolons(css)
216    css = condense_zero_units(css)
217    css = condense_multidimensional_zeros(css)
218    css = condense_floating_points(css)
219    css = normalize_rgb_colors_to_hex(css)
220    css = condense_hex_colors(css)
221    if wrap is not None:
222        css = wrap_css_lines(css, wrap)
223    css = css.replace("___PSEUDOCLASSBMH___", '"\\"}\\""')
224    css = condense_semicolons(css)
225    return css.strip()
226
227
228def main():
229    import optparse
230    import sys
231
232    p = optparse.OptionParser(
233        prog="cssmin", version=__version__,
234        usage="%prog [--wrap N]",
235        description="""Reads raw CSS from stdin, and writes compressed CSS to stdout.""")
236
237    p.add_option(
238        '-w', '--wrap', type='int', default=None, metavar='N',
239        help="Wrap output to approximately N chars per line.")
240
241    options, args = p.parse_args()
242    sys.stdout.write(cssmin(sys.stdin.read(), wrap=options.wrap))
243
244
245if __name__ == '__main__':
246    main()
247