Add support for transforming style rules to ebook-convert

This commit is contained in:
Kovid Goyal 2016-03-10 14:47:14 +05:30
parent debc7438d2
commit 941f395ca3
5 changed files with 143 additions and 36 deletions

View File

@ -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)

View File

@ -3,7 +3,7 @@ __license__ = 'GPL 3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__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)

View File

@ -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)

View File

@ -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 = {}

View File

@ -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 <i>{property}</i> <b>{match_type}</b> <b>{query}</b><br>{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 += ' <code>%s</code>' % ad