diff --git a/src/calibre/ebooks/conversion/cli.py b/src/calibre/ebooks/conversion/cli.py index 88dedc0640..1a09ce2025 100644 --- a/src/calibre/ebooks/conversion/cli.py +++ b/src/calibre/ebooks/conversion/cli.py @@ -98,6 +98,15 @@ def option_recommendation_to_cli_option(add_option, rec): attrs.pop('type', '') if opt.name == 'read_metadata_from_opf': switches.append('--from-opf') + if opt.name == 'transform_css_rules': + attrs['help'] = _( + 'Path to a file containing rules to transform the CSS styles' + ' in this book. The easiest way to create such a file is to' + ' use the wizard for creating rules in the calibre GUI. Access' + ' it in the "Look & Feel->Transform styles" section of the conversion' + ' dialog. Once you create the rules, you can use the Export button' + ' to save them to a file.' + ) if opt.name in DEFAULT_TRUE_OPTIONS and rec.recommended_value is True: switches = ['--disable-'+opt.long_switch] add_option(Option(*switches, **attrs)) @@ -176,7 +185,7 @@ def add_pipeline_options(parser, plumber): 'subset_embedded_fonts', 'embed_all_fonts', 'line_height', 'minimum_line_height', 'linearize_tables', - 'extra_css', 'filter_css', 'expand_css', + 'extra_css', 'filter_css', 'transform_css_rules', 'expand_css', 'smarten_punctuation', 'unsmarten_punctuation', 'margin_top', 'margin_left', 'margin_right', 'margin_bottom', 'change_justification', @@ -349,6 +358,17 @@ def main(args=sys.argv): setattr(opts, x, abspath(getattr(opts, x))) if opts.search_replace: opts.search_replace = read_sr_patterns(opts.search_replace, log) + if opts.transform_css_rules: + from calibre.ebooks.css_transform_rules import import_rules, validate_rule + with open(opts.transform_css_rules, 'rb') as tcr: + opts.transform_css_rules = rules = list(import_rules(tcr.read())) + for rule in rules: + title, msg = validate_rule(rule) + if title and msg: + log.error('Failed to parse CSS transform rules') + log.error(title) + log.error(msg) + return 1 recommendations = [(n.dest, getattr(opts, n.dest), OptionRecommendation.HIGH) diff --git a/src/calibre/ebooks/conversion/plumber.py b/src/calibre/ebooks/conversion/plumber.py index 097729ccef..0fd2d8fe26 100644 --- a/src/calibre/ebooks/conversion/plumber.py +++ b/src/calibre/ebooks/conversion/plumber.py @@ -3,7 +3,7 @@ __license__ = 'GPL 3' __copyright__ = '2009, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import os, re, sys, shutil, pprint +import os, re, sys, shutil, pprint, json from functools import partial from calibre.customize.conversion import OptionRecommendation, DummyReporter @@ -355,6 +355,12 @@ OptionRecommendation(name='extra_css', 'rules.') ), +OptionRecommendation(name='transform_css_rules', + recommended_value=None, level=OptionRecommendation.LOW, + help=_('Rules for transforming the styles in this book. These' + ' rules are applied after all other CSS processing is done.') + ), + OptionRecommendation(name='filter_css', recommended_value=None, level=OptionRecommendation.LOW, help=_('A comma separated list of CSS properties that ' @@ -1153,12 +1159,18 @@ OptionRecommendation(name='search_replace', mobi_file_type = getattr(self.opts, 'mobi_file_type', 'old') needs_old_markup = (self.output_plugin.file_type == 'lit' or (self.output_plugin.file_type == 'mobi' and mobi_file_type == 'old')) + transform_css_rules = () + if self.opts.transform_css_rules: + transform_css_rules = self.opts.transform_css_rules + if isinstance(transform_css_rules, basestring): + transform_css_rules = json.loads(transform_css_rules) flattener = CSSFlattener(fbase=fbase, fkey=fkey, lineh=line_height, untable=needs_old_markup, unfloat=needs_old_markup, page_break_on_body=self.output_plugin.file_type in ('mobi', 'lit'), + transform_css_rules=transform_css_rules, specializer=partial(self.output_plugin.specialize_css_for_output, self.log, self.opts)) flattener(self.oeb, self.opts) diff --git a/src/calibre/ebooks/css_transform_rules.py b/src/calibre/ebooks/css_transform_rules.py index 551c9f5620..0740cab644 100644 --- a/src/calibre/ebooks/css_transform_rules.py +++ b/src/calibre/ebooks/css_transform_rules.py @@ -5,6 +5,7 @@ from __future__ import (unicode_literals, division, absolute_import, print_function) from functools import partial +from collections import OrderedDict import operator from cssutils.css import Property, CSSRule @@ -192,7 +193,42 @@ class Rule(object): 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') @@ -203,6 +239,9 @@ def validate_rule(rule): 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']) @@ -218,6 +257,9 @@ def validate_rule(rule): 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': @@ -258,6 +300,47 @@ def transform_container(container, serialized_rules, names=()): 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('%s: %s' % (k, v.replace('\n', ' ')) for k, v in rule.iteritems() if k in allowed_keys) + lines.append('') + return '\n'.join(lines).encode('utf-8') + +def import_rules(raw_data): + pat = regex.compile('\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(): # {{{ import unittest @@ -335,6 +418,10 @@ def test(): # {{{ 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') + 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) unittest.TextTestRunner(verbosity=4).run(tests) diff --git a/src/calibre/ebooks/oeb/transforms/flatcss.py b/src/calibre/ebooks/oeb/transforms/flatcss.py index 54f53762b0..35144da646 100644 --- a/src/calibre/ebooks/oeb/transforms/flatcss.py +++ b/src/calibre/ebooks/oeb/transforms/flatcss.py @@ -136,8 +136,13 @@ class EmbedFontsCSSRules(object): class CSSFlattener(object): def __init__(self, fbase=None, fkey=None, lineh=None, unfloat=False, - untable=False, page_break_on_body=False, specializer=None): + untable=False, page_break_on_body=False, specializer=None, + transform_css_rules=()): self.fbase = fbase + self.transform_css_rules = transform_css_rules + if self.transform_css_rules: + from calibre.ebooks.css_transform_rules import compile_rules + self.transform_css_rules = compile_rules(self.transform_css_rules) self.fkey = fkey self.lineh = lineh self.unfloat = unfloat @@ -554,8 +559,11 @@ class CSSFlattener(object): if item.media_type in OEB_STYLES: manifest.remove(item) id, href = manifest.generate('css', 'stylesheet.css') - item = manifest.add(id, href, CSS_MIME, data=cssutils.parseString(css, - validate=False)) + sheet = cssutils.parseString(css, validate=False) + if self.transform_css_rules: + from calibre.ebooks.css_transform_rules import transform_sheet + transform_sheet(self.transform_css_rules, sheet) + item = manifest.add(id, href, CSS_MIME, data=sheet) self.oeb.manifest.main_stylesheet = item return href @@ -584,8 +592,11 @@ class CSSFlattener(object): href = None if css.strip(): id_, href = manifest.generate('page_css', 'page_styles.css') - manifest.add(id_, href, CSS_MIME, data=cssutils.parseString(css, - validate=False)) + sheet = cssutils.parseString(css, validate=False) + if self.transform_css_rules: + from calibre.ebooks.css_transform_rules import transform_sheet + transform_sheet(self.transform_css_rules, sheet) + manifest.add(id_, href, CSS_MIME, data=sheet) gc_map[css] = href ans = {} diff --git a/src/calibre/gui2/css_transform_rules.py b/src/calibre/gui2/css_transform_rules.py index d30e7af1b5..68b9604671 100644 --- a/src/calibre/gui2/css_transform_rules.py +++ b/src/calibre/gui2/css_transform_rules.py @@ -4,13 +4,13 @@ from __future__ import (unicode_literals, division, absolute_import, print_function) -from collections import OrderedDict from PyQt5.Qt import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QLineEdit, QPushButton, QSize ) -from calibre.ebooks.css_transform_rules import validate_rule, safe_parser, compile_rules, transform_sheet +from calibre.ebooks.css_transform_rules import ( + validate_rule, safe_parser, compile_rules, transform_sheet, ACTION_MAP, MATCH_TYPE_MAP) from calibre.gui2 import error_dialog, elided_text from calibre.gui2.tag_mapper import ( RuleEditDialog as RuleEditDialogBase, Rules as RulesBase, RulesDialog as @@ -20,29 +20,6 @@ from calibre.utils.config import JSONConfig class RuleEdit(QWidget): # {{{ - 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')), - )) MSG = _('Create the rule below, the rule can be used to transform style properties') def __init__(self, parent=None): @@ -69,7 +46,7 @@ class RuleEdit(QWidget): # {{{ '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(): + for action, text in MATCH_TYPE_MAP.iteritems(): w.addItem(text, action) w.currentIndexChanged.connect(self.update_state) elif clause == '{query}': @@ -89,7 +66,7 @@ class RuleEdit(QWidget): # {{{ for clause in parts: if clause == '{action}': self.action = w = QComboBox(self) - for action, text in self.ACTION_MAP.iteritems(): + for action, text in ACTION_MAP.iteritems(): w.addItem(text, action) w.currentIndexChanged.connect(self.update_state) elif clause == '{action_data}': @@ -178,8 +155,8 @@ class RuleItem(RuleItemBase): # {{{ query = elided_text(rule['query'], font=parent.font(), width=200, pos='right') text = _( 'If the property {property} {match_type} {query}
{action}').format( - property=rule['property'], action=RuleEdit.ACTION_MAP[rule['action']], - match_type=RuleEdit.MATCH_TYPE_MAP[rule['match_type']], query=query) + property=rule['property'], action=ACTION_MAP[rule['action']], + match_type=MATCH_TYPE_MAP[rule['match_type']], query=query) if rule['action_data']: ad = elided_text(rule['action_data'], font=parent.font(), width=200, pos='right') text += ' %s' % ad