mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Add support for transforming style rules to ebook-convert
This commit is contained in:
parent
debc7438d2
commit
941f395ca3
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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 = {}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user