%PDF- %PDF-
| Direktori : /lib/calibre/calibre/ebooks/ |
| Current File : //lib/calibre/calibre/ebooks/css_transform_rules.py |
#!/usr/bin/env python3
# License: GPLv3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
from functools import partial
from collections import OrderedDict
import operator, numbers
from css_parser.css import Property, CSSRule
from calibre import force_unicode
from calibre.ebooks import parse_css_length
from calibre.ebooks.oeb.normalize_css import normalizers, safe_parser
from polyglot.builtins import iteritems
def compile_pat(pat):
import regex
REGEX_FLAGS = regex.VERSION1 | regex.UNICODE | regex.IGNORECASE
return regex.compile(pat, flags=REGEX_FLAGS)
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:
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):
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 iteritems(n(p.name, p.propertyValue))]
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 sorted(props, key=operator.attrgetter('name')):
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, match_pat=None):
if parent_prop is not None:
self.expand_property(parent_prop)
if match_pat is None:
prop.value = val
else:
prop.value = match_pat.sub(val, prop.value)
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, parent=self.css_declaration))
def set_property(self, name, value, priority='', replace=True):
# Note that this does not handle shorthand properties, so you must
# call remove_property() yourself in that case
self.changed = True
if replace:
self.css_declaration.removeProperty(name)
self.css_declaration.setProperty(Property(name, value, priority, parent=self.css_declaration))
def __str__(self):
return force_unicode(self.css_declaration.cssText, 'utf-8')
operator_map = {'==':'eq', '!=': 'ne', '<=':'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=None):
if isinstance(raw, numbers.Number):
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, default_unit='')
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:
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
self.match_pat = None
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.lower()
elif match_type == 'is_not':
self.property_matches = lambda x: x.lower() != query.lower()
elif match_type == '*':
self.property_matches = lambda x: True
elif 'matches' in match_type:
self.match_pat = compile_pat(query)
if match_type.startswith('not_'):
self.property_matches = lambda x: self.match_pat.match(x) is None
else:
self.property_matches = lambda x: self.match_pat.match(x) is not None
else:
value, unit = parse_css_length_or_number(query)
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 tuple(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, self.match_pat)
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
ACTION_MAP = OrderedDict((
('remove', _('Remove the property')),
('append', _('Add extra properties')),
('change', _('Change the value to')),
('*', _('Multiply the value by')),
('/', _('Divide the value by')),
('+', _('Add to the value')),
('-', _('Subtract from the value')),
))
MATCH_TYPE_MAP = OrderedDict((
('is', _('is')),
('is_not', _('is not')),
('*', _('is any value')),
('matches', _('matches pattern')),
('not_matches', _('does not match pattern')),
('==', _('is the same length as')),
('!=', _('is not the same length as')),
('<', _('is less than')),
('>', _('is greater than')),
('<=', _('is less than or equal to')),
('>=', _('is greater than or equal to')),
))
allowed_keys = frozenset('property match_type query action action_data'.split())
def validate_rule(rule):
keys = frozenset(rule)
extra = keys - allowed_keys
if extra:
return _('Unknown keys'), _(
'The rule has unknown keys: %s') % ', '.join(extra)
missing = allowed_keys - keys
if missing:
return _('Missing keys'), _(
'The rule has missing keys: %s') % ', '.join(missing)
mt = rule['match_type']
if not rule['property']:
return _('Property required'), _('You must specify a CSS property to match')
if rule['property'] in normalizers:
return _('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.').format(rule['property'])
if not rule['query'] and mt != '*':
_('Query required'), _(
'You must specify a value for the CSS property to match')
if mt not in MATCH_TYPE_MAP:
return _('Unknown match type'), _(
'The match type %s is not known') % mt
if 'matches' in mt:
try:
compile_pat(rule['query'])
except Exception:
return _('Query invalid'), _(
'%s is not a valid regular expression') % rule['query']
elif mt in '< > <= >= == !='.split():
try:
num = parse_css_length_or_number(rule['query'])[0]
if num is None:
raise Exception('not a number')
except Exception:
return _('Query invalid'), _(
'%s is not a valid length or number') % rule['query']
ac, ad = rule['action'], rule['action_data']
if ac not in ACTION_MAP:
return _('Unknown action type'), _(
'The action type %s is not known') % mt
if not ad and ac != 'remove':
msg = _('You must specify a number')
if ac == 'append':
msg = _('You must specify at least one CSS property to add')
elif ac == 'change':
msg = _('You must specify a value to change the property to')
return _('No data'), msg
if ac in '+-*/':
try:
float(ad)
except Exception:
return _('Invalid number'), _('%s is not a number') % ad
return None, None
def compile_rules(serialized_rules):
return [Rule(**r) for r in serialized_rules]
def transform_declaration(compiled_rules, decl):
decl = StyleDeclaration(decl)
changed = False
for rule in compiled_rules:
if rule.process_declaration(decl):
changed = True
return changed
def transform_sheet(compiled_rules, sheet):
changed = False
for rule in sheet.cssRules.rulesOfType(CSSRule.STYLE_RULE):
if transform_declaration(compiled_rules, rule.style):
changed = True
return changed
def transform_container(container, serialized_rules, names=()):
from calibre.ebooks.oeb.polish.css import transform_css
rules = compile_rules(serialized_rules)
return transform_css(
container, transform_sheet=partial(transform_sheet, rules),
transform_style=partial(transform_declaration, rules), names=names
)
def rule_to_text(rule):
def get(prop):
return rule.get(prop) or ''
text = _(
'If the property {property} {match_type} {query}\n{action}').format(
property=get('property'), action=ACTION_MAP[rule['action']],
match_type=MATCH_TYPE_MAP[rule['match_type']], query=get('query'))
if get('action_data'):
text += get('action_data')
return text
def export_rules(serialized_rules):
lines = []
for rule in serialized_rules:
lines.extend('# ' + l for l in rule_to_text(rule).splitlines())
lines.extend('{}: {}'.format(k, v.replace('\n', ' ')) for k, v in iteritems(rule) if k in allowed_keys)
lines.append('')
return '\n'.join(lines).encode('utf-8')
def import_rules(raw_data):
import regex
pat = regex.compile(r'\s*(\S+)\s*:\s*(.+)', flags=regex.VERSION1)
current_rule = {}
def sanitize(r):
return {k:(r.get(k) or '') for k in allowed_keys}
for line in raw_data.decode('utf-8').splitlines():
if not line.strip():
if current_rule:
yield sanitize(current_rule)
current_rule = {}
continue
if line.lstrip().startswith('#'):
continue
m = pat.match(line)
if m is not None:
k, v = m.group(1).lower(), m.group(2)
if k in allowed_keys:
current_rule[k] = v
if current_rule:
yield sanitize(current_rule)
def test(return_tests=False): # {{{
import unittest
def apply_rule(style, **rule):
r = Rule(**rule)
decl = StyleDeclaration(safe_parser().parseStyle(style))
r.process_declaration(decl)
return str(decl)
class TestTransforms(unittest.TestCase):
longMessage = True
maxDiff = None
ae = unittest.TestCase.assertEqual
def test_matching(self):
def m(match_type='*', query='', action_data=''):
action = 'change' if action_data else 'remove'
self.ae(apply_rule(
css, property=prop, match_type=match_type, query=query, action=action, action_data=action_data
), ecss)
prop = 'font-size'
css, ecss = 'font-size: 1.2rem', 'font-size: 1.2em'
m('matches', query='(.+)rem', action_data=r'\1em')
prop = 'color'
css, ecss = 'color: red; margin: 0', 'margin: 0'
m('*')
m('is', 'red')
m('is_not', 'blue')
m('matches', 'R.d')
m('not_matches', 'blue')
ecss = css.replace('; ', ';\n')
m('is', 'blue')
css, ecss = 'color: currentColor; line-height: 0', 'line-height: 0'
m('is', 'currentColor')
prop = 'margin-top'
css, ecss = 'color: red; margin-top: 10', 'color: red'
m('*')
m('==', '10')
m('!=', '11')
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')
def test_expansion(self):
def m(css, ecss, action='remove', action_data=''):
self.ae(ecss, apply_rule(css, property=prop, action=action, action_data=action_data))
prop = 'margin-top'
m('margin: 0', 'margin-bottom: 0;\nmargin-left: 0;\nmargin-right: 0')
m('margin: 0 !important', 'margin-bottom: 0 !important;\nmargin-left: 0 !important;\nmargin-right: 0 !important')
m('margin: 0', 'margin-bottom: 0;\nmargin-left: 0;\nmargin-right: 0;\nmargin-top: 1pt', 'change', '1pt')
prop = 'font-family'
m('font: 10em "Kovid Goyal", monospace', 'font-size: 10em;\nfont-style: normal;\nfont-variant: normal;\nfont-weight: normal;\nline-height: normal')
def test_append(self):
def m(css, ecss, action_data=''):
self.ae(ecss, apply_rule(css, property=prop, action='append', action_data=action_data))
prop = 'color'
m('color: red', 'color: red;\nmargin: 1pt;\nfont-weight: bold', 'margin: 1pt; font-weight: bold')
def test_change(self):
def m(css, ecss, action='change', action_data=''):
self.ae(ecss, apply_rule(css, property=prop, action=action, action_data=action_data))
prop = 'font-family'
m('font-family: a, b', 'font-family: "c c", d', action_data='"c c", d')
prop = 'line-height'
m('line-height: 1', 'line-height: 3', '*', '3')
m('line-height: 1em', 'line-height: 4em', '+', '3')
m('line-height: 1', 'line-height: 0', '-', '1')
m('line-height: 2', 'line-height: 1', '/', '2')
prop = 'border-top-width'
m('border-width: 1', 'border-bottom-width: 1;\nborder-left-width: 1;\nborder-right-width: 1;\nborder-top-width: 3', '*', '3')
prop = 'font-size'
def test_export_import(self):
rule = {'property':'a', 'match_type':'*', 'query':'some text', 'action':'remove', 'action_data':'color: red; a: b'}
self.ae(rule, next(import_rules(export_rules([rule]))))
tests = unittest.defaultTestLoader.loadTestsFromTestCase(TestTransforms)
if return_tests:
return tests
unittest.TextTestRunner(verbosity=4).run(tests)
if __name__ == '__main__':
test()
# }}}