mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
More work on CSS transformations
This commit is contained in:
parent
b7d068290c
commit
a2d7f549f9
@ -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()
|
||||
# }}}
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user