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', '') attrs.pop('type', '')
if opt.name == 'read_metadata_from_opf': if opt.name == 'read_metadata_from_opf':
switches.append('--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: if opt.name in DEFAULT_TRUE_OPTIONS and rec.recommended_value is True:
switches = ['--disable-'+opt.long_switch] switches = ['--disable-'+opt.long_switch]
add_option(Option(*switches, **attrs)) add_option(Option(*switches, **attrs))
@ -176,7 +185,7 @@ def add_pipeline_options(parser, plumber):
'subset_embedded_fonts', 'embed_all_fonts', 'subset_embedded_fonts', 'embed_all_fonts',
'line_height', 'minimum_line_height', 'line_height', 'minimum_line_height',
'linearize_tables', 'linearize_tables',
'extra_css', 'filter_css', 'expand_css', 'extra_css', 'filter_css', 'transform_css_rules', 'expand_css',
'smarten_punctuation', 'unsmarten_punctuation', 'smarten_punctuation', 'unsmarten_punctuation',
'margin_top', 'margin_left', 'margin_right', 'margin_top', 'margin_left', 'margin_right',
'margin_bottom', 'change_justification', 'margin_bottom', 'change_justification',
@ -349,6 +358,17 @@ def main(args=sys.argv):
setattr(opts, x, abspath(getattr(opts, x))) setattr(opts, x, abspath(getattr(opts, x)))
if opts.search_replace: if opts.search_replace:
opts.search_replace = read_sr_patterns(opts.search_replace, log) 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), recommendations = [(n.dest, getattr(opts, n.dest),
OptionRecommendation.HIGH) OptionRecommendation.HIGH)

View File

@ -3,7 +3,7 @@ __license__ = 'GPL 3'
__copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>' __copyright__ = '2009, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
import os, re, sys, shutil, pprint import os, re, sys, shutil, pprint, json
from functools import partial from functools import partial
from calibre.customize.conversion import OptionRecommendation, DummyReporter from calibre.customize.conversion import OptionRecommendation, DummyReporter
@ -355,6 +355,12 @@ OptionRecommendation(name='extra_css',
'rules.') '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', OptionRecommendation(name='filter_css',
recommended_value=None, level=OptionRecommendation.LOW, recommended_value=None, level=OptionRecommendation.LOW,
help=_('A comma separated list of CSS properties that ' 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') mobi_file_type = getattr(self.opts, 'mobi_file_type', 'old')
needs_old_markup = (self.output_plugin.file_type == 'lit' or needs_old_markup = (self.output_plugin.file_type == 'lit' or
(self.output_plugin.file_type == 'mobi' and mobi_file_type == 'old')) (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, flattener = CSSFlattener(fbase=fbase, fkey=fkey,
lineh=line_height, lineh=line_height,
untable=needs_old_markup, untable=needs_old_markup,
unfloat=needs_old_markup, unfloat=needs_old_markup,
page_break_on_body=self.output_plugin.file_type in ('mobi', page_break_on_body=self.output_plugin.file_type in ('mobi',
'lit'), 'lit'),
transform_css_rules=transform_css_rules,
specializer=partial(self.output_plugin.specialize_css_for_output, specializer=partial(self.output_plugin.specialize_css_for_output,
self.log, self.opts)) self.log, self.opts))
flattener(self.oeb, self.opts) flattener(self.oeb, self.opts)

View File

@ -5,6 +5,7 @@
from __future__ import (unicode_literals, division, absolute_import, from __future__ import (unicode_literals, division, absolute_import,
print_function) print_function)
from functools import partial from functools import partial
from collections import OrderedDict
import operator import operator
from cssutils.css import Property, CSSRule from cssutils.css import Property, CSSRule
@ -192,7 +193,42 @@ class Rule(object):
declaration.changed = oval or changed declaration.changed = oval or changed
return 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): 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'] mt = rule['match_type']
if not rule['property']: if not rule['property']:
return _('Property required'), _('You must specify a CSS property to match') 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 != '*': if not rule['query'] and mt != '*':
_('Query required'), _( _('Query required'), _(
'You must specify a value for the CSS property to match') '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: if 'matches' in mt:
try: try:
compile_pat(rule['query']) compile_pat(rule['query'])
@ -218,6 +257,9 @@ def validate_rule(rule):
return _('Query invalid'), _( return _('Query invalid'), _(
'%s is not a valid length or number') % rule['query'] '%s is not a valid length or number') % rule['query']
ac, ad = rule['action'], rule['action_data'] 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': if not ad and ac != 'remove':
msg = _('You must specify a number') msg = _('You must specify a number')
if ac == 'append': if ac == 'append':
@ -258,6 +300,47 @@ def transform_container(container, serialized_rules, names=()):
transform_style=partial(transform_declaration, rules), names=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(): # {{{ def test(): # {{{
import unittest import unittest
@ -335,6 +418,10 @@ def test(): # {{{
prop = 'border-top-width' 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') 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) tests = unittest.defaultTestLoader.loadTestsFromTestCase(TestTransforms)
unittest.TextTestRunner(verbosity=4).run(tests) unittest.TextTestRunner(verbosity=4).run(tests)

View File

@ -136,8 +136,13 @@ class EmbedFontsCSSRules(object):
class CSSFlattener(object): class CSSFlattener(object):
def __init__(self, fbase=None, fkey=None, lineh=None, unfloat=False, 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.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.fkey = fkey
self.lineh = lineh self.lineh = lineh
self.unfloat = unfloat self.unfloat = unfloat
@ -554,8 +559,11 @@ class CSSFlattener(object):
if item.media_type in OEB_STYLES: if item.media_type in OEB_STYLES:
manifest.remove(item) manifest.remove(item)
id, href = manifest.generate('css', 'stylesheet.css') id, href = manifest.generate('css', 'stylesheet.css')
item = manifest.add(id, href, CSS_MIME, data=cssutils.parseString(css, sheet = cssutils.parseString(css, validate=False)
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 self.oeb.manifest.main_stylesheet = item
return href return href
@ -584,8 +592,11 @@ class CSSFlattener(object):
href = None href = None
if css.strip(): if css.strip():
id_, href = manifest.generate('page_css', 'page_styles.css') id_, href = manifest.generate('page_css', 'page_styles.css')
manifest.add(id_, href, CSS_MIME, data=cssutils.parseString(css, sheet = cssutils.parseString(css, validate=False)
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 gc_map[css] = href
ans = {} ans = {}

View File

@ -4,13 +4,13 @@
from __future__ import (unicode_literals, division, absolute_import, from __future__ import (unicode_literals, division, absolute_import,
print_function) print_function)
from collections import OrderedDict
from PyQt5.Qt import ( from PyQt5.Qt import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QLineEdit, QPushButton, QSize 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 import error_dialog, elided_text
from calibre.gui2.tag_mapper import ( from calibre.gui2.tag_mapper import (
RuleEditDialog as RuleEditDialogBase, Rules as RulesBase, RulesDialog as RuleEditDialog as RuleEditDialogBase, Rules as RulesBase, RulesDialog as
@ -20,29 +20,6 @@ from calibre.utils.config import JSONConfig
class RuleEdit(QWidget): # {{{ 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') MSG = _('Create the rule below, the rule can be used to transform style properties')
def __init__(self, parent=None): def __init__(self, parent=None):
@ -69,7 +46,7 @@ class RuleEdit(QWidget): # {{{
'For instance use margin-top, not margin.')) 'For instance use margin-top, not margin.'))
elif clause == '{match_type}': elif clause == '{match_type}':
self.match_type = w = QComboBox(self) 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.addItem(text, action)
w.currentIndexChanged.connect(self.update_state) w.currentIndexChanged.connect(self.update_state)
elif clause == '{query}': elif clause == '{query}':
@ -89,7 +66,7 @@ class RuleEdit(QWidget): # {{{
for clause in parts: for clause in parts:
if clause == '{action}': if clause == '{action}':
self.action = w = QComboBox(self) 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.addItem(text, action)
w.currentIndexChanged.connect(self.update_state) w.currentIndexChanged.connect(self.update_state)
elif clause == '{action_data}': elif clause == '{action_data}':
@ -178,8 +155,8 @@ class RuleItem(RuleItemBase): # {{{
query = elided_text(rule['query'], font=parent.font(), width=200, pos='right') query = elided_text(rule['query'], font=parent.font(), width=200, pos='right')
text = _( text = _(
'If the property <i>{property}</i> <b>{match_type}</b> <b>{query}</b><br>{action}').format( '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']], property=rule['property'], action=ACTION_MAP[rule['action']],
match_type=RuleEdit.MATCH_TYPE_MAP[rule['match_type']], query=query) match_type=MATCH_TYPE_MAP[rule['match_type']], query=query)
if rule['action_data']: if rule['action_data']:
ad = elided_text(rule['action_data'], font=parent.font(), width=200, pos='right') ad = elided_text(rule['action_data'], font=parent.font(), width=200, pos='right')
text += ' <code>%s</code>' % ad text += ' <code>%s</code>' % ad