From 1359f475af726a4f214a79083c323d066a51c773 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 8 Jun 2014 18:09:47 +0530 Subject: [PATCH] Edit Book: Live CSS: Fix clicking on link to go to style definition not working if the stylesheet contains CSS 3 media queries Adds support for CSS 3 media query parsing to tinycss --- .../gui2/tweak_book/editor/smart/css.py | 2 +- src/tinycss/__init__.py | 13 ++- src/tinycss/css21.py | 8 +- src/tinycss/media3.py | 104 ++++++++++++++++++ src/tinycss/tests/css21.py | 2 +- src/tinycss/tests/media3.py | 66 +++++++++++ 6 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 src/tinycss/media3.py create mode 100644 src/tinycss/tests/media3.py diff --git a/src/calibre/gui2/tweak_book/editor/smart/css.py b/src/calibre/gui2/tweak_book/editor/smart/css.py index fce627d12b..5cc2134dd4 100644 --- a/src/calibre/gui2/tweak_book/editor/smart/css.py +++ b/src/calibre/gui2/tweak_book/editor/smart/css.py @@ -10,7 +10,7 @@ from calibre.gui2.tweak_book.editor.smart import NullSmarts def find_rule(raw, rule_address): import tinycss - parser = tinycss.make_parser('page3', 'fonts3') + parser = tinycss.make_full_parser() sheet = parser.parse_stylesheet(raw) rules = sheet.rules ans = None, None diff --git a/src/tinycss/__init__.py b/src/tinycss/__init__.py index eb981e0e48..70ad9a870a 100644 --- a/src/tinycss/__init__.py +++ b/src/tinycss/__init__.py @@ -12,14 +12,16 @@ from .version import VERSION __version__ = VERSION -from .css21 import CSS21Parser -from .page3 import CSSPage3Parser -from .fonts3 import CSSFonts3Parser +from tinycss.css21 import CSS21Parser +from tinycss.page3 import CSSPage3Parser +from tinycss.fonts3 import CSSFonts3Parser +from tinycss.media3 import CSSMedia3Parser PARSER_MODULES = { 'page3': CSSPage3Parser, 'fonts3': CSSFonts3Parser, + 'media3': CSSMedia3Parser, } @@ -42,3 +44,8 @@ def make_parser(*features, **kwargs): else: parser_class = CSS21Parser return parser_class(**kwargs) + +def make_full_parser(**kwargs): + ''' A parser that parses all supported CSS 3 modules in addition to CSS 2.1 ''' + features = tuple(PARSER_MODULES.iterkeys()) + return make_parser(*features, **kwargs) diff --git a/src/tinycss/css21.py b/src/tinycss/css21.py index befe9bf385..2f1d3f85bb 100644 --- a/src/tinycss/css21.py +++ b/src/tinycss/css21.py @@ -544,9 +544,7 @@ class CSS21Parser(object): def parse_media_rule(self, rule, previous_rules, errors, context): if context != 'stylesheet': raise ParseError(rule, '@media rule not allowed in ' + context) - if not rule.head: - raise ParseError(rule, 'expected media types for @media') - media = self.parse_media(rule.head) + media = self.parse_media(rule.head, errors) if rule.body is None: raise ParseError(rule, 'invalid {0} rule: missing block'.format(rule.at_keyword)) @@ -575,7 +573,7 @@ class CSS21Parser(object): 'expected URI or STRING for @import rule, got ' + head[0].type) uri = head[0].value - media = self.parse_media(strip_whitespace(head[1:])) + media = self.parse_media(strip_whitespace(head[1:]), errors) if rule.body is not None: # The position of the ';' token would be best, but we don’t # have it anymore here. @@ -585,7 +583,7 @@ class CSS21Parser(object): def parse_charset_rule(self, rule, previous_rules, errors, context): raise ParseError(rule, 'mis-placed or malformed @charset rule') - def parse_media(self, tokens): + def parse_media(self, tokens, errors): """For CSS 2.1, parse a list of media types. Media Queries are expected to override this. diff --git a/src/tinycss/media3.py b/src/tinycss/media3.py new file mode 100644 index 0000000000..1baba30c99 --- /dev/null +++ b/src/tinycss/media3.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Kovid Goyal ' + +from tinycss.css21 import CSS21Parser +from tinycss.parsing import remove_whitespace, split_on_comma, ParseError + +class MediaQuery(object): + + __slots__ = 'media_type', 'expressions', 'negated' + + def __init__(self, media_type='all', expressions=(), negated=False): + self.media_type = media_type + self.expressions = expressions + self.negated = negated + + def __repr__(self): + return '' % ( + self.media_type, self.negated, self.expressions) + + def __eq__(self, other): + return self.media_type == getattr(other, 'media_type', None) and \ + self.negated == getattr(other, 'negated', None) and \ + self.expressions == getattr(other, 'expressions', None) + +class MalformedExpression(Exception): + + def __init__(self, tok, msg): + Exception.__init__(self, msg) + self.tok = tok + +class CSSMedia3Parser(CSS21Parser): + + ''' Parse media queries as defined by the CSS 3 media module ''' + + def parse_media(self, tokens, errors): + if not tokens: + return [MediaQuery('all')] + queries = [] + + for part in split_on_comma(remove_whitespace(tokens)): + negated = False + media_type = None + expressions = [] + try: + for i, tok in enumerate(part): + if i == 0 and tok.type == 'IDENT': + val = tok.value.lower() + if val == 'only': + continue # ignore leading ONLY + if val == 'not': + negated = True + continue + if media_type is None and tok.type == 'IDENT': + media_type = tok.value + continue + elif media_type is None: + media_type = 'all' + + if tok.type == 'IDENT' and tok.value.lower() == 'and': + continue + if not tok.is_container: + raise MalformedExpression(tok, 'expected a media expression not a %s' % tok.type) + if tok.type != '(': + raise MalformedExpression(tok, 'media expressions must be in parentheses not %s' % tok.type) + content = remove_whitespace(tok.content) + if len(content) == 0: + raise MalformedExpression(tok, 'media expressions cannot be empty') + if content[0].type != 'IDENT': + raise MalformedExpression(content[0], 'expected a media feature not a %s' % tok.type) + media_feature, expr = content[0].value, None + if len(content) > 1: + if len(content) < 3: + raise MalformedExpression(content[1], 'malformed media feature definition') + if content[1].type != ':': + raise MalformedExpression(content[1], 'expected a :') + expr = content[2:] + if len(expr) == 1: + expr = expr[0] + elif len(expr) == 3 and (expr[0].type, expr[1].type, expr[1].value, expr[2].type) == ( + 'INTEGER', 'DELIM', '/', 'INTEGER'): + # This should really be moved into token_data, but + # since RATIO is not part of CSS 2.1 and does not + # occur anywhere else, we special case it here. + r = expr[0] + r.value = (expr[0].value, expr[2].value) + r.type = 'RATIO' + r._as_css = expr[0]._as_css + expr[1]._as_css + expr[2]._as_css + expr = r + else: + raise MalformedExpression(expr[0], 'malformed media feature definition') + + expressions.append((media_feature, expr)) + except MalformedExpression as err: + errors.extend(ParseError(err.tok, err.message)) + media_type, negated, expressions = 'all', True, () + queries.append(MediaQuery(media_type or 'all', expressions=tuple(expressions), negated=negated)) + + return queries + diff --git a/src/tinycss/tests/css21.py b/src/tinycss/tests/css21.py index f3d04eb83b..4231e66b85 100644 --- a/src/tinycss/tests/css21.py +++ b/src/tinycss/tests/css21.py @@ -303,10 +303,10 @@ class TestCSS21(BaseTest): def test_at_media(self): for (css_source, expected_rules, expected_errors) in [ (' /* hey */\n', [], []), + ('@media {}', [(['all'], [])], []), ('@media all {}', [(['all'], [])], []), ('@media screen, print {}', [(['screen', 'print'], [])], []), ('@media all;', [], ['invalid @media rule: missing block']), - ('@media {}', [], ['expected media types for @media']), ('@media 4 {}', [], ['expected a media type, got INTEGER']), ('@media , screen {}', [], ['expected a media type']), ('@media screen, {}', [], ['expected a media type']), diff --git a/src/tinycss/tests/media3.py b/src/tinycss/tests/media3.py new file mode 100644 index 0000000000..44369546af --- /dev/null +++ b/src/tinycss/tests/media3.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2014, Kovid Goyal ' + +from tinycss.media3 import CSSMedia3Parser, MediaQuery as MQ +from tinycss.tests import BaseTest, jsonify + +def jsonify_expr(e): + if e is None: + return None + return next(jsonify([e])) + +def jsonify_expressions(mqlist): + for mq in mqlist: + mq.expressions = tuple( + (k, jsonify_expr(e)) for k, e in mq.expressions) + return mqlist + +class TestFonts3(BaseTest): + + def test_media_queries(self): + 'Test parsing of media queries from the CSS 3 media module' + for css, media_query_list, expected_errors in [ + # CSS 2.1 (simple media queries) + ('@media {}', [MQ()], []), + ('@media all {}', [MQ()], []), + ('@media screen {}', [MQ('screen')], []), + ('@media , screen {}', [MQ(), MQ('screen')], []), + ('@media screen, {}', [MQ('screen'), MQ()], []), + + # Examples from the CSS 3 specs + ('@media screen and (color) {}', [MQ('screen', (('color', None),))], []), + ('@media all and (min-width:500px) {}', [ + MQ('all', (('min-width', ('DIMENSION', 500)),))], []), + ('@media (min-width:500px) {}', [ + MQ('all', (('min-width', ('DIMENSION', 500)),))], []), + ('@media (orientation: portrait) {}', [ + MQ('all', (('orientation', ('IDENT', 'portrait')),))], []), + ('@media screen and (color), projection and (color) {}', [ + MQ('screen', (('color', None),)), MQ('projection', (('color', None),)),], []), + ('@media not screen and (color) {}', [ + MQ('screen', (('color', None),), True)], []), + ('@media only screen and (color) {}', [ + MQ('screen', (('color', None),))], []), + ('@media aural and (device-aspect-ratio: 16/9) {}', [ + MQ('aural', (('device-aspect-ratio', ('RATIO', (16, 9))),))], []), + ('@media (resolution: 166dpi) {}', [ + MQ('all', (('resolution', ('DIMENSION', 166)),))], []), + ('@media (min-resolution: 166DPCM) {}', [ + MQ('all', (('min-resolution', ('DIMENSION', 166)),))], []), + + # Malformed media queries + ('@media (example, all,), speech {}', [MQ(negated=True), MQ('speech')], ['expected a :']), + ('@media &test, screen {}', [MQ(negated=True), MQ('screen')], ['expected a media expression not a DELIM']), + + ]: + stylesheet = CSSMedia3Parser().parse_stylesheet(css) + self.assert_errors(stylesheet.errors, expected_errors) + self.ae(len(stylesheet.rules), 1) + rule = stylesheet.rules[0] + self.ae(jsonify_expressions(rule.media), media_query_list) +