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', '')
|
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)
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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 = {}
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user