More work on CSS transformations

This commit is contained in:
Kovid Goyal 2016-03-09 15:59:39 +05:30
parent b7d068290c
commit a2d7f549f9
2 changed files with 260 additions and 16 deletions

View File

@ -4,13 +4,238 @@
from __future__ import (unicode_literals, division, absolute_import,
print_function)
from functools import partial
import operator
from cssutils.css import Property
import regex
REGEX_FLAGS = regex.VERSION1 | regex.UNICODE
from calibre import force_unicode
from calibre.ebooks import parse_css_length
from calibre.ebooks.oeb.normalize_css import normalizers, safe_parser
REGEX_FLAGS = regex.VERSION1 | regex.UNICODE | regex.IGNORECASE
def compile_pat(pat):
return regex.compile(pat, flags=REGEX_FLAGS)
def parse_length(raw):
raise NotImplementedError('TODO: implement this')
def all_properties(decl):
' This is needed because CSSStyleDeclaration.getProperties(None, all=True) does not work and is slower than it needs to be. '
for item in decl.seq:
p = item.value
if isinstance(p, Property):
yield p
class StyleDeclaration(object):
def __init__(self, css_declaration):
self.css_declaration = css_declaration
self.expanded_properties = {}
self.changed = False
def __iter__(self):
dec = self.css_declaration
for p in all_properties(dec):
if isinstance(p, Property):
n = normalizers.get(p.name)
if n is None:
yield p, None
else:
if p not in self.expanded_properties:
self.expanded_properties[p] = [Property(k, v, p.literalpriority) for k, v in n(p.name, p.propertyValue).iteritems()]
for ep in self.expanded_properties[p]:
yield ep, p
def expand_property(self, parent_prop):
props = self.expanded_properties.pop(parent_prop, None)
if props is None:
return
dec = self.css_declaration
seq = dec._tempSeq()
for item in dec.seq:
if item.value is parent_prop:
for c in props:
c.parent = dec
seq.append(c, 'Property')
else:
seq.appendItem(item)
dec._setSeq(seq)
def remove_property(self, prop, parent_prop):
if parent_prop is not None:
self.expand_property(parent_prop)
dec = self.css_declaration
seq = dec._tempSeq()
for item in dec.seq:
if item.value is not prop:
seq.appendItem(item)
dec._setSeq(seq)
self.changed = True
def change_property(self, prop, parent_prop, val):
if parent_prop is not None:
self.expand_property(parent_prop)
prop.value = val
self.changed = True
def append_properties(self, props):
if props:
self.changed = True
for prop in props:
self.css_declaration.setProperty(Property(prop.name, prop.value, prop.literalpriority, self.css_declaration))
def __str__(self):
return force_unicode(self.css_declaration.cssText, 'utf-8')
operator_map = {'==':'eq', '<=':'le', '<':'lt', '>=':'ge', '>':'gt', '-':'sub', '+': 'add', '*':'mul', '/':'truediv'}
def unit_convert(value, unit, dpi=96.0, body_font_size=12):
result = None
if unit == 'px':
result = value * 72.0 / dpi
elif unit == 'in':
result = value * 72.0
elif unit == 'pt':
result = value
elif unit == 'pc':
result = value * 12.0
elif unit == 'mm':
result = value * 2.8346456693
elif unit == 'cm':
result = value * 28.346456693
elif unit == 'rem':
result = value * body_font_size
elif unit == 'q':
result = value * 0.708661417325
return result
def parse_css_length_or_number(raw, default_unit='px'):
if isinstance(raw, (int, long, float)):
return raw, default_unit
try:
return float(raw), default_unit
except Exception:
return parse_css_length(raw)
def numeric_match(value, unit, pts, op, raw):
try:
v, u = parse_css_length_or_number(raw)
except Exception:
return False
if v is None:
return False
if unit is None or u is None or unit == u:
return op(v, value)
if pts is None:
return False
p = unit_convert(v, u)
if p is None:
return False
return op(p, pts)
def transform_number(val, op, raw):
try:
v, u = parse_css_length_or_number(raw)
except Exception:
return raw
if v is None:
return raw
v = op(v, val)
if int(v) == v:
v = int(v)
return str(v) + u
class Rule(object):
def __init__(self, property='color', match_type='*', query='', action='remove', action_data=''):
self.property_name = property.lower()
self.action, self.action_data = action, action_data
if self.action == 'append':
decl = safe_parser().parseStyle(self.action_data)
self.appended_properties = list(all_properties(decl))
elif self.action in '+-/*':
self.action_operator = partial(transform_number, float(self.action_data), getattr(operator, operator_map[self.action]))
if match_type == 'is':
self.property_matches = lambda x: x.lower() == query
elif match_type == '*':
self.property_matches = lambda x: True
elif 'matches' in match_type:
q = compile_pat(query)
if match_type.startswith('not_'):
self.property_matches = lambda x: q.match(x) is None
else:
self.property_matches = lambda x: q.match(x) is not None
else:
value, unit = parse_css_length_or_number(query, default_unit=None)
op = getattr(operator, operator_map[match_type])
pts = unit_convert(value, unit)
self.property_matches = partial(numeric_match, value, unit, pts, op)
def process_declaration(self, declaration):
oval, declaration.changed = declaration.changed, False
for prop, parent_prop in declaration:
if prop.name == self.property_name and self.property_matches(prop.value):
if self.action == 'remove':
declaration.remove_property(prop, parent_prop)
elif self.action == 'change':
declaration.change_property(prop, parent_prop, self.action_data)
elif self.action == 'append':
declaration.append_properties(self.appended_properties)
else:
val = prop.value
nval = self.action_operator(val)
if val != nval:
declaration.change_property(prop, parent_prop, nval)
changed = declaration.changed
declaration.changed = oval or changed
return changed
def test(): # {{{
import unittest
class TestTransforms(unittest.TestCase):
longMessage = True
maxDiff = None
ae = unittest.TestCase.assertEqual
def test_matching(self):
def apply_rule(style, **rule):
r = Rule(**rule)
decl = StyleDeclaration(safe_parser().parseStyle(style))
r.process_declaration(decl)
return str(decl)
def m(match_type='*', query=''):
self.ae(ecss, apply_rule(css, property=prop, match_type=match_type, query=query))
prop = 'color'
css, ecss = 'color: red; margin: 0', 'margin: 0'
m('*')
m('is', 'red')
m('matches', 'R.d')
m('not_matches', 'blue')
ecss = css.replace('; ', ';\n')
m('is', 'blue')
prop = 'margin-top'
css, ecss = 'color: red; margin-top: 10', 'color: red'
m('*')
m('==', '10')
m('<=', '10')
m('>=', '10')
m('<', '11')
m('>', '9')
css, ecss = 'color: red; margin-top: 1mm', 'color: red'
m('==', '1')
m('==', '1mm')
m('==', '4q')
ecss = css.replace('; ', ';\n')
m('==', '1pt')
tests = unittest.defaultTestLoader.loadTestsFromTestCase(TestTransforms)
unittest.TextTestRunner(verbosity=4).run(tests)
if __name__ == '__main__':
test()
# }}}

View File

@ -10,7 +10,8 @@ from PyQt5.Qt import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QLineEdit, QListWidgetItem
)
from calibre.ebooks.css_transform_rules import compile_pat, parse_length
from calibre.ebooks.css_transform_rules import compile_pat, parse_css_length_or_number
from calibre.ebooks.oeb.normalize_css import SHORTHAND_DEFAULTS
from calibre.gui2 import error_dialog, elided_text
from calibre.gui2.tag_mapper import RuleEditDialog as RuleEditDialogBase, Rules as RulesBase
@ -27,13 +28,15 @@ class RuleEdit(QWidget): # {{{
))
MATCH_TYPE_MAP = OrderedDict((
('==', _('is')),
('is', _('is')),
('*', _('is any value')),
('matches', _('matches pattern')),
('not_matches', _('does not match pattern'))
('==', _('is the same length as')),
('<', _('is less than')),
('>', _('is greater than')),
('<=', _('is less than or equal to')),
('>=', _('is greater than or equal to')),
('matches', _('matches pattern')),
('not_matches', _('does not match pattern'))
))
def __init__(self, parent=None):
@ -55,7 +58,9 @@ class RuleEdit(QWidget): # {{{
self.preamble = w = QLabel(_('If the &property:'))
elif clause == '{property}':
self.property = w = QLineEdit(self)
w.setToolTip(_('The name of a CSS property, for example: font-size\n'))
w.setToolTip(_('The name of a CSS property, for example: font-size\n'
'Do not use shorthand properties, they will not work.\n'
'For instance use margin-top, not margin.'))
elif clause == '{match_type}':
self.match_type = w = QComboBox(self)
for action, text in self.MATCH_TYPE_MAP.iteritems():
@ -97,17 +102,20 @@ class RuleEdit(QWidget): # {{{
self.action_data.setVisible(r['action'] != 'remove')
tt = _('The CSS property value')
mt = r['match_type']
self.query.setVisible(mt != '*')
if 'matches' in mt:
tt = _('A regular expression')
elif mt in '< > <= >='.split():
tt = _('Either a CSS length, such as 10pt or a unit less number. If a unitless'
' number is used it will compared with the CSS value using whatever unit'
' the value has.')
' number is used it will be compared with the CSS value using whatever unit'
' the value has. Note that comparison automatically converts units, except'
' for relative units like percentage or em, for which comparison fails'
' if the units are different.')
self.query.setToolTip(tt)
tt = ''
ac = r['action']
if ac == 'append':
tt = _('CSS properties for to add to the rule that contains the matching style. You'
tt = _('CSS properties to add to the rule that contains the matching style. You'
' can specify more than one property, separated by semi-colons, for example:'
' color:red; font-weight: bold')
elif ac in '+=*/':
@ -117,7 +125,7 @@ class RuleEdit(QWidget): # {{{
@property
def rule(self):
return {
'property':self.property.text().strip(),
'property':self.property.text().strip().lower(),
'match_type': self.match_type.currentData(),
'query': self.query.text().strip(),
'action': self.action.currentData(),
@ -138,11 +146,20 @@ class RuleEdit(QWidget): # {{{
def validate(self):
rule = self.rule
if not rule['query']:
mt = rule['match_type']
if not rule['property']:
error_dialog(self, _('Property required'), _(
'You must specify a CSS property to match'), show=True)
return False
if rule['property'] in SHORTHAND_DEFAULTS:
error_dialog(self, _('Shorthand property not allowed'), _(
'{0} is a shorthand property. Use the full form of the property,'
' for example, instead of font, use font-family, instead of margin, use margin-top, etc.'), show=True)
return False
if not rule['query'] and mt != '*':
error_dialog(self, _('Query required'), _(
'You must specify a value for the CSS property to match'), show=True)
return False
mt = rule['match_type']
if 'matches' in mt:
try:
compile_pat(rule['query'])
@ -150,9 +167,11 @@ class RuleEdit(QWidget): # {{{
error_dialog(self, _('Query invalid'), _(
'%s is not a valid regular expression') % rule['query'], show=True)
return False
elif mt in '< > <= >='.split():
elif mt in '< > <= >= =='.split():
try:
parse_length(rule['query'])
num = parse_css_length_or_number(rule['query'])[0]
if num is None:
raise Exception('not a number')
except Exception:
error_dialog(self, _('Query invalid'), _(
'%s is not a valid length or number') % rule['query'], show=True)