Merge "Minor CSS cleanup for Vector and Monobook skins"
[mediawiki.git] / maintenance / cssjanus / cssjanus.py
blobdd14bd58d0a04047b848a6a8da9858c727068446
1 #!/usr/bin/python
3 # Copyright 2008 Google Inc. All Rights Reserved.
5 """Converts a LeftToRight Cascading Style Sheet into a RightToLeft one.
7 This is a utility script for replacing "left" oriented things in a CSS file
8 like float, padding, margin with "right" oriented values.
9 It also does the opposite.
10 The goal is to be able to conditionally serve one large, cat'd, compiled CSS
11 file appropriate for LeftToRight oriented languages and RightToLeft ones.
12 This utility will hopefully help your structural layout done in CSS in
13 terms of its RTL compatibility. It will not help with some of the more
14 complicated bidirectional text issues.
15 """
17 __author__ = 'elsigh@google.com (Lindsey Simon)'
18 __version__ = '0.1'
20 import logging
21 import re
22 import sys
23 import getopt
24 import os
26 import csslex
28 logging.getLogger().setLevel(logging.INFO)
30 # Global for the command line flags.
31 SWAP_LTR_RTL_IN_URL_DEFAULT = False
32 SWAP_LEFT_RIGHT_IN_URL_DEFAULT = False
33 FLAGS = {'swap_ltr_rtl_in_url': SWAP_LTR_RTL_IN_URL_DEFAULT,
34 'swap_left_right_in_url': SWAP_LEFT_RIGHT_IN_URL_DEFAULT}
36 # Generic token delimiter character.
37 TOKEN_DELIMITER = '~'
39 # This is a temporary match token we use when swapping strings.
40 TMP_TOKEN = '%sTMP%s' % (TOKEN_DELIMITER, TOKEN_DELIMITER)
42 # Token to be used for joining lines.
43 TOKEN_LINES = '%sJ%s' % (TOKEN_DELIMITER, TOKEN_DELIMITER)
45 # Global constant text strings for CSS value matches.
46 LTR = 'ltr'
47 RTL = 'rtl'
48 LEFT = 'left'
49 RIGHT = 'right'
51 # This is a lookbehind match to ensure that we don't replace instances
52 # of our string token (left, rtl, etc...) if there's a letter in front of it.
53 # Specifically, this prevents replacements like 'background: url(bright.png)'.
54 LOOKBEHIND_NOT_LETTER = r'(?<![a-zA-Z])'
56 # This is a lookahead match to make sure we don't replace left and right
57 # in actual classnames, so that we don't break the HTML/CSS dependencies.
58 # Read literally, it says ignore cases where the word left, for instance, is
59 # directly followed by valid classname characters and a curly brace.
60 # ex: .column-left {float: left} will become .column-left {float: right}
61 LOOKAHEAD_NOT_OPEN_BRACE = (r'(?!(?:%s|%s|%s|#|\:|\.|\,|\+|>)*?{)' %
62 (csslex.NMCHAR, TOKEN_LINES, csslex.SPACE))
65 # These two lookaheads are to test whether or not we are within a
66 # background: url(HERE) situation.
67 # Ref: http://www.w3.org/TR/CSS21/syndata.html#uri
68 VALID_AFTER_URI_CHARS = r'[\'\"]?%s' % csslex.WHITESPACE
69 LOOKAHEAD_NOT_CLOSING_PAREN = r'(?!%s?%s\))' % (csslex.URL_CHARS,
70 VALID_AFTER_URI_CHARS)
71 LOOKAHEAD_FOR_CLOSING_PAREN = r'(?=%s?%s\))' % (csslex.URL_CHARS,
72 VALID_AFTER_URI_CHARS)
74 # Compile a regex to swap left and right values in 4 part notations.
75 # We need to match negatives and decimal numeric values.
76 # ex. 'margin: .25em -2px 3px 0' becomes 'margin: .25em 0 3px -2px'.
77 POSSIBLY_NEGATIVE_QUANTITY = r'((?:-?%s)|(?:inherit|auto))' % csslex.QUANTITY
78 POSSIBLY_NEGATIVE_QUANTITY_SPACE = r'%s%s%s' % (POSSIBLY_NEGATIVE_QUANTITY,
79 csslex.SPACE,
80 csslex.WHITESPACE)
81 FOUR_NOTATION_QUANTITY_RE = re.compile(r'%s%s%s%s' %
82 (POSSIBLY_NEGATIVE_QUANTITY_SPACE,
83 POSSIBLY_NEGATIVE_QUANTITY_SPACE,
84 POSSIBLY_NEGATIVE_QUANTITY_SPACE,
85 POSSIBLY_NEGATIVE_QUANTITY),
86 re.I)
87 COLOR = r'(%s|%s)' % (csslex.NAME, csslex.HASH)
88 COLOR_SPACE = r'%s%s' % (COLOR, csslex.SPACE)
89 FOUR_NOTATION_COLOR_RE = re.compile(r'(-color%s:%s)%s%s%s(%s)' %
90 (csslex.WHITESPACE,
91 csslex.WHITESPACE,
92 COLOR_SPACE,
93 COLOR_SPACE,
94 COLOR_SPACE,
95 COLOR),
96 re.I)
98 # Compile the cursor resize regexes
99 CURSOR_EAST_RE = re.compile(LOOKBEHIND_NOT_LETTER + '([ns]?)e-resize')
100 CURSOR_WEST_RE = re.compile(LOOKBEHIND_NOT_LETTER + '([ns]?)w-resize')
102 # Matches the condition where we need to replace the horizontal component
103 # of a background-position value when expressed in horizontal percentage.
104 # Had to make two regexes because in the case of position-x there is only
105 # one quantity, and otherwise we don't want to match and change cases with only
106 # one quantity.
107 BG_HORIZONTAL_PERCENTAGE_RE = re.compile(r'background(-position)?(%s:%s)'
108 '([^%%]*?)(%s)%%'
109 '(%s(?:%s|%s))' % (csslex.WHITESPACE,
110 csslex.WHITESPACE,
111 csslex.NUM,
112 csslex.WHITESPACE,
113 csslex.QUANTITY,
114 csslex.IDENT))
116 BG_HORIZONTAL_PERCENTAGE_X_RE = re.compile(r'background-position-x(%s:%s)'
117 '(%s)%%' % (csslex.WHITESPACE,
118 csslex.WHITESPACE,
119 csslex.NUM))
121 # Matches the opening of a body selector.
122 BODY_SELECTOR = r'body%s{%s' % (csslex.WHITESPACE, csslex.WHITESPACE)
124 # Matches anything up until the closing of a selector.
125 CHARS_WITHIN_SELECTOR = r'[^\}]*?'
127 # Matches the direction property in a selector.
128 DIRECTION_RE = r'direction%s:%s' % (csslex.WHITESPACE, csslex.WHITESPACE)
130 # These allow us to swap "ltr" with "rtl" and vice versa ONLY within the
131 # body selector and on the same line.
132 BODY_DIRECTION_LTR_RE = re.compile(r'(%s)(%s)(%s)(ltr)' %
133 (BODY_SELECTOR, CHARS_WITHIN_SELECTOR,
134 DIRECTION_RE),
135 re.I)
136 BODY_DIRECTION_RTL_RE = re.compile(r'(%s)(%s)(%s)(rtl)' %
137 (BODY_SELECTOR, CHARS_WITHIN_SELECTOR,
138 DIRECTION_RE),
139 re.I)
142 # Allows us to swap "direction:ltr" with "direction:rtl" and
143 # vice versa anywhere in a line.
144 DIRECTION_LTR_RE = re.compile(r'%s(ltr)' % DIRECTION_RE)
145 DIRECTION_RTL_RE = re.compile(r'%s(rtl)' % DIRECTION_RE)
147 # We want to be able to switch left with right and vice versa anywhere
148 # we encounter left/right strings, EXCEPT inside the background:url(). The next
149 # two regexes are for that purpose. We have alternate IN_URL versions of the
150 # regexes compiled in case the user passes the flag that they do
151 # actually want to have left and right swapped inside of background:urls.
152 LEFT_RE = re.compile('%s(%s)%s%s' % (LOOKBEHIND_NOT_LETTER,
153 LEFT,
154 LOOKAHEAD_NOT_CLOSING_PAREN,
155 LOOKAHEAD_NOT_OPEN_BRACE),
156 re.I)
157 RIGHT_RE = re.compile('%s(%s)%s%s' % (LOOKBEHIND_NOT_LETTER,
158 RIGHT,
159 LOOKAHEAD_NOT_CLOSING_PAREN,
160 LOOKAHEAD_NOT_OPEN_BRACE),
161 re.I)
162 LEFT_IN_URL_RE = re.compile('%s(%s)%s' % (LOOKBEHIND_NOT_LETTER,
163 LEFT,
164 LOOKAHEAD_FOR_CLOSING_PAREN),
165 re.I)
166 RIGHT_IN_URL_RE = re.compile('%s(%s)%s' % (LOOKBEHIND_NOT_LETTER,
167 RIGHT,
168 LOOKAHEAD_FOR_CLOSING_PAREN),
169 re.I)
170 LTR_IN_URL_RE = re.compile('%s(%s)%s' % (LOOKBEHIND_NOT_LETTER,
171 LTR,
172 LOOKAHEAD_FOR_CLOSING_PAREN),
173 re.I)
174 RTL_IN_URL_RE = re.compile('%s(%s)%s' % (LOOKBEHIND_NOT_LETTER,
175 RTL,
176 LOOKAHEAD_FOR_CLOSING_PAREN),
177 re.I)
179 COMMENT_RE = re.compile('(%s)' % csslex.COMMENT, re.I)
181 NOFLIP_TOKEN = r'\@noflip'
182 # The NOFLIP_TOKEN inside of a comment. For now, this requires that comments
183 # be in the input, which means users of a css compiler would have to run
184 # this script first if they want this functionality.
185 NOFLIP_ANNOTATION = r'/\*%s%s%s\*/' % (csslex.WHITESPACE,
186 NOFLIP_TOKEN,
187 csslex. WHITESPACE)
189 # After a NOFLIP_ANNOTATION, and within a class selector, we want to be able
190 # to set aside a single rule not to be flipped. We can do this by matching
191 # our NOFLIP annotation and then using a lookahead to make sure there is not
192 # an opening brace before the match.
193 NOFLIP_SINGLE_RE = re.compile(r'(%s%s[^;}]+;?)' % (NOFLIP_ANNOTATION,
194 LOOKAHEAD_NOT_OPEN_BRACE),
195 re.I)
197 # After a NOFLIP_ANNOTATION, we want to grab anything up until the next } which
198 # means the entire following class block. This will prevent all of its
199 # declarations from being flipped.
200 NOFLIP_CLASS_RE = re.compile(r'(%s%s})' % (NOFLIP_ANNOTATION,
201 CHARS_WITHIN_SELECTOR),
202 re.I)
205 class Tokenizer:
206 """Replaces any CSS comments with string tokens and vice versa."""
208 def __init__(self, token_re, token_string):
209 """Constructor for the Tokenizer.
211 Args:
212 token_re: A regex for the string to be replace by a token.
213 token_string: The string to put between token delimiters when tokenizing.
215 logging.debug('Tokenizer::init token_string=%s' % token_string)
216 self.token_re = token_re
217 self.token_string = token_string
218 self.originals = []
220 def Tokenize(self, line):
221 """Replaces any string matching token_re in line with string tokens.
223 By passing a function as an argument to the re.sub line below, we bypass
224 the usual rule where re.sub will only replace the left-most occurrence of
225 a match by calling the passed in function for each occurrence.
227 Args:
228 line: A line to replace token_re matches in.
230 Returns:
231 line: A line with token_re matches tokenized.
233 line = self.token_re.sub(self.TokenizeMatches, line)
234 logging.debug('Tokenizer::Tokenize returns: %s' % line)
235 return line
237 def DeTokenize(self, line):
238 """Replaces tokens with the original string.
240 Args:
241 line: A line with tokens.
243 Returns:
244 line with any tokens replaced by the original string.
247 # Put all of the comments back in by their comment token.
248 for i, original in enumerate(self.originals):
249 token = '%s%s_%s%s' % (TOKEN_DELIMITER, self.token_string, i + 1,
250 TOKEN_DELIMITER)
251 line = line.replace(token, original)
252 logging.debug('Tokenizer::DeTokenize i:%s w/%s' % (i, token))
253 logging.debug('Tokenizer::DeTokenize returns: %s' % line)
254 return line
256 def TokenizeMatches(self, m):
257 """Replaces matches with tokens and stores the originals.
259 Args:
260 m: A match object.
262 Returns:
263 A string token which replaces the CSS comment.
265 logging.debug('Tokenizer::TokenizeMatches %s' % m.group(1))
266 self.originals.append(m.group(1))
267 return '%s%s_%s%s' % (TOKEN_DELIMITER,
268 self.token_string,
269 len(self.originals),
270 TOKEN_DELIMITER)
273 def FixBodyDirectionLtrAndRtl(line):
274 """Replaces ltr with rtl and vice versa ONLY in the body direction.
276 Args:
277 line: A string to replace instances of ltr with rtl.
278 Returns:
279 line with direction: ltr and direction: rtl swapped only in body selector.
280 line = FixBodyDirectionLtrAndRtl('body { direction:ltr }')
281 line will now be 'body { direction:rtl }'.
284 line = BODY_DIRECTION_LTR_RE.sub('\\1\\2\\3%s' % TMP_TOKEN, line)
285 line = BODY_DIRECTION_RTL_RE.sub('\\1\\2\\3%s' % LTR, line)
286 line = line.replace(TMP_TOKEN, RTL)
287 logging.debug('FixBodyDirectionLtrAndRtl returns: %s' % line)
288 return line
291 def FixLeftAndRight(line):
292 """Replaces left with right and vice versa in line.
294 Args:
295 line: A string in which to perform the replacement.
297 Returns:
298 line with left and right swapped. For example:
299 line = FixLeftAndRight('padding-left: 2px; margin-right: 1px;')
300 line will now be 'padding-right: 2px; margin-left: 1px;'.
303 line = LEFT_RE.sub(TMP_TOKEN, line)
304 line = RIGHT_RE.sub(LEFT, line)
305 line = line.replace(TMP_TOKEN, RIGHT)
306 logging.debug('FixLeftAndRight returns: %s' % line)
307 return line
310 def FixLeftAndRightInUrl(line):
311 """Replaces left with right and vice versa ONLY within background urls.
313 Args:
314 line: A string in which to replace left with right and vice versa.
316 Returns:
317 line with left and right swapped in the url string. For example:
318 line = FixLeftAndRightInUrl('background:url(right.png)')
319 line will now be 'background:url(left.png)'.
322 line = LEFT_IN_URL_RE.sub(TMP_TOKEN, line)
323 line = RIGHT_IN_URL_RE.sub(LEFT, line)
324 line = line.replace(TMP_TOKEN, RIGHT)
325 logging.debug('FixLeftAndRightInUrl returns: %s' % line)
326 return line
329 def FixLtrAndRtlInUrl(line):
330 """Replaces ltr with rtl and vice versa ONLY within background urls.
332 Args:
333 line: A string in which to replace ltr with rtl and vice versa.
335 Returns:
336 line with left and right swapped. For example:
337 line = FixLtrAndRtlInUrl('background:url(rtl.png)')
338 line will now be 'background:url(ltr.png)'.
341 line = LTR_IN_URL_RE.sub(TMP_TOKEN, line)
342 line = RTL_IN_URL_RE.sub(LTR, line)
343 line = line.replace(TMP_TOKEN, RTL)
344 logging.debug('FixLtrAndRtlInUrl returns: %s' % line)
345 return line
348 def FixCursorProperties(line):
349 """Fixes directional CSS cursor properties.
351 Args:
352 line: A string to fix CSS cursor properties in.
354 Returns:
355 line reformatted with the cursor properties substituted. For example:
356 line = FixCursorProperties('cursor: ne-resize')
357 line will now be 'cursor: nw-resize'.
360 line = CURSOR_EAST_RE.sub('\\1' + TMP_TOKEN, line)
361 line = CURSOR_WEST_RE.sub('\\1e-resize', line)
362 line = line.replace(TMP_TOKEN, 'w-resize')
363 logging.debug('FixCursorProperties returns: %s' % line)
364 return line
367 def FixFourPartNotation(line):
368 """Fixes the second and fourth positions in 4 part CSS notation.
370 Args:
371 line: A string to fix 4 part CSS notation in.
373 Returns:
374 line reformatted with the 4 part notations swapped. For example:
375 line = FixFourPartNotation('padding: 1px 2px 3px 4px')
376 line will now be 'padding: 1px 4px 3px 2px'.
378 line = FOUR_NOTATION_QUANTITY_RE.sub('\\1 \\4 \\3 \\2', line)
379 line = FOUR_NOTATION_COLOR_RE.sub('\\1\\2 \\5 \\4 \\3', line)
380 logging.debug('FixFourPartNotation returns: %s' % line)
381 return line
384 def FixBackgroundPosition(line):
385 """Fixes horizontal background percentage values in line.
387 Args:
388 line: A string to fix horizontal background position values in.
390 Returns:
391 line reformatted with the 4 part notations swapped.
393 line = BG_HORIZONTAL_PERCENTAGE_RE.sub(CalculateNewBackgroundPosition, line)
394 line = BG_HORIZONTAL_PERCENTAGE_X_RE.sub(CalculateNewBackgroundPositionX,
395 line)
396 logging.debug('FixBackgroundPosition returns: %s' % line)
397 return line
400 def CalculateNewBackgroundPosition(m):
401 """Fixes horizontal background-position percentages.
403 This function should be used as an argument to re.sub since it needs to
404 perform replacement specific calculations.
406 Args:
407 m: A match object.
409 Returns:
410 A string with the horizontal background position percentage fixed.
411 BG_HORIZONTAL_PERCENTAGE_RE.sub(FixBackgroundPosition,
412 'background-position: 75% 50%')
413 will return 'background-position: 25% 50%'.
416 # The flipped value is the offset from 100%
417 new_x = str(100-int(m.group(4)))
419 # Since m.group(1) may very well be None type and we need a string..
420 if m.group(1):
421 position_string = m.group(1)
422 else:
423 position_string = ''
425 return 'background%s%s%s%s%%%s' % (position_string, m.group(2), m.group(3),
426 new_x, m.group(5))
429 def CalculateNewBackgroundPositionX(m):
430 """Fixes percent based background-position-x.
432 This function should be used as an argument to re.sub since it needs to
433 perform replacement specific calculations.
435 Args:
436 m: A match object.
438 Returns:
439 A string with the background-position-x percentage fixed.
440 BG_HORIZONTAL_PERCENTAGE_X_RE.sub(CalculateNewBackgroundPosition,
441 'background-position-x: 75%')
442 will return 'background-position-x: 25%'.
445 # The flipped value is the offset from 100%
446 new_x = str(100-int(m.group(2)))
448 return 'background-position-x%s%s%%' % (m.group(1), new_x)
451 def ChangeLeftToRightToLeft(lines,
452 swap_ltr_rtl_in_url=None,
453 swap_left_right_in_url=None):
454 """Turns lines into a stream and runs the fixing functions against it.
456 Args:
457 lines: An list of CSS lines.
458 swap_ltr_rtl_in_url: Overrides this flag if param is set.
459 swap_left_right_in_url: Overrides this flag if param is set.
461 Returns:
462 The same lines, but with left and right fixes.
465 global FLAGS
467 # Possibly override flags with params.
468 logging.debug('ChangeLeftToRightToLeft swap_ltr_rtl_in_url=%s, '
469 'swap_left_right_in_url=%s' % (swap_ltr_rtl_in_url,
470 swap_left_right_in_url))
471 if swap_ltr_rtl_in_url is None:
472 swap_ltr_rtl_in_url = FLAGS['swap_ltr_rtl_in_url']
473 if swap_left_right_in_url is None:
474 swap_left_right_in_url = FLAGS['swap_left_right_in_url']
476 # Turns the array of lines into a single line stream.
477 logging.debug('LINES COUNT: %s' % len(lines))
478 line = TOKEN_LINES.join(lines)
480 # Tokenize any single line rules with the /* noflip */ annotation.
481 noflip_single_tokenizer = Tokenizer(NOFLIP_SINGLE_RE, 'NOFLIP_SINGLE')
482 line = noflip_single_tokenizer.Tokenize(line)
484 # Tokenize any class rules with the /* noflip */ annotation.
485 noflip_class_tokenizer = Tokenizer(NOFLIP_CLASS_RE, 'NOFLIP_CLASS')
486 line = noflip_class_tokenizer.Tokenize(line)
488 # Tokenize the comments so we can preserve them through the changes.
489 comment_tokenizer = Tokenizer(COMMENT_RE, 'C')
490 line = comment_tokenizer.Tokenize(line)
492 # Here starteth the various left/right orientation fixes.
493 line = FixBodyDirectionLtrAndRtl(line)
495 if swap_left_right_in_url:
496 line = FixLeftAndRightInUrl(line)
498 if swap_ltr_rtl_in_url:
499 line = FixLtrAndRtlInUrl(line)
501 line = FixLeftAndRight(line)
502 line = FixCursorProperties(line)
503 line = FixFourPartNotation(line)
504 line = FixBackgroundPosition(line)
506 # DeTokenize the single line noflips.
507 line = noflip_single_tokenizer.DeTokenize(line)
509 # DeTokenize the class-level noflips.
510 line = noflip_class_tokenizer.DeTokenize(line)
512 # DeTokenize the comments.
513 line = comment_tokenizer.DeTokenize(line)
515 # Rejoin the lines back together.
516 lines = line.split(TOKEN_LINES)
518 return lines
520 def usage():
521 """Prints out usage information."""
523 print 'Usage:'
524 print ' ./cssjanus.py < file.css > file-rtl.css'
525 print 'Flags:'
526 print ' --swap_left_right_in_url: Fixes "left"/"right" string within urls.'
527 print ' Ex: ./cssjanus.py --swap_left_right_in_url < file.css > file_rtl.css'
528 print ' --swap_ltr_rtl_in_url: Fixes "ltr"/"rtl" string within urls.'
529 print ' Ex: ./cssjanus --swap_ltr_rtl_in_url < file.css > file_rtl.css'
531 def setflags(opts):
532 """Parse the passed in command line arguments and set the FLAGS global.
534 Args:
535 opts: getopt iterable intercepted from argv.
538 global FLAGS
540 # Parse the arguments.
541 for opt, arg in opts:
542 logging.debug('opt: %s, arg: %s' % (opt, arg))
543 if opt in ("-h", "--help"):
544 usage()
545 sys.exit()
546 elif opt in ("-d", "--debug"):
547 logging.getLogger().setLevel(logging.DEBUG)
548 elif opt == '--swap_ltr_rtl_in_url':
549 FLAGS['swap_ltr_rtl_in_url'] = True
550 elif opt == '--swap_left_right_in_url':
551 FLAGS['swap_left_right_in_url'] = True
554 def main(argv):
555 """Sends stdin lines to ChangeLeftToRightToLeft and writes to stdout."""
557 # Define the flags.
558 try:
559 opts, args = getopt.getopt(argv, 'hd', ['help', 'debug',
560 'swap_left_right_in_url',
561 'swap_ltr_rtl_in_url'])
562 except getopt.GetoptError:
563 usage()
564 sys.exit(2)
566 # Parse and set the flags.
567 setflags(opts)
569 # Call the main routine with all our functionality.
570 fixed_lines = ChangeLeftToRightToLeft(sys.stdin.readlines())
571 sys.stdout.write(''.join(fixed_lines))
573 if __name__ == '__main__':
574 main(sys.argv[1:])