diff --git a/src/calibre/ebooks/oeb/polish/css.py b/src/calibre/ebooks/oeb/polish/css.py index dd7dcb5cd8..6911709b77 100644 --- a/src/calibre/ebooks/oeb/polish/css.py +++ b/src/calibre/ebooks/oeb/polish/css.py @@ -6,6 +6,7 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2014, Kovid Goyal ' +from collections import defaultdict from functools import partial from cssutils.css import CSSRule, CSSStyleDeclaration @@ -48,12 +49,35 @@ def get_imported_sheets(name, container, sheets, recursion_level=10, sheet=None) ans.discard(name) return ans -def remove_unused_css(container, report=None, remove_unused_classes=False): + +def merge_declarations(first, second): + for prop in second.getProperties(): + first.setProperty(prop) + + +def merge_identical_selectors(sheet): + ' Merge rules that have identical selectors ' + selector_map = defaultdict(list) + for rule in sheet.cssRules.rulesOfType(CSSRule.STYLE_RULE): + selector_map[rule.selectorText].append(rule) + remove = [] + for rule_group in selector_map.itervalues(): + if len(rule_group) > 1: + for i in range(1, len(rule_group)): + merge_declarations(rule_group[0].style, rule_group[i].style) + remove.append(rule_group[i]) + for rule in remove: + sheet.cssRules.remove(rule) + return len(remove) + + +def remove_unused_css(container, report=None, remove_unused_classes=False, merge_rules=False): ''' Remove all unused CSS rules from the book. An unused CSS rule is one that does not match any actual content. :param report: An optional callable that takes a single argument. It is called with information about the operations being performed. :param remove_unused_classes: If True, class attributes in the HTML that do not match any CSS rules are also removed. + :param merge_rules: If True, rules with identical selectors are merged. ''' report = report or (lambda x:x) @@ -64,6 +88,13 @@ def remove_unused_css(container, report=None, remove_unused_classes=False): pass sheets = {name:safe_parse(name) for name, mt in container.mime_map.iteritems() if mt in OEB_STYLES} sheets = {k:v for k, v in sheets.iteritems() if v is not None} + num_merged = 0 + if merge_rules: + for name, sheet in sheets.iteritems(): + num = merge_identical_selectors(sheet) + if num: + container.dirty(name) + num_merged += num import_map = {name:get_imported_sheets(name, container, sheets) for name in sheets} if remove_unused_classes: class_map = {name:{icu_lower(x) for x in classes_in_rule_list(sheet.cssRules)} for name, sheet in sheets.iteritems()} @@ -80,6 +111,11 @@ def remove_unused_css(container, report=None, remove_unused_classes=False): for style in root.xpath('//*[local-name()="style"]'): if style.get('type', 'text/css') == 'text/css' and style.text: sheet = container.parse_css(style.text) + if merge_rules: + num = merge_identical_selectors(sheet) + if num: + num_merged += num + container.dirty(name) if remove_unused_classes: used_classes |= {icu_lower(x) for x in classes_in_rule_list(sheet.cssRules)} imports = get_imported_sheets(name, container, sheets, sheet=sheet) @@ -130,18 +166,24 @@ def remove_unused_css(container, report=None, remove_unused_classes=False): [sheet.cssRules.remove(r) for r in unused_rules] container.dirty(name) - if num_of_removed_rules > 0: - report(ngettext('Removed %d unused CSS style rule', 'Removed %d unused CSS style rules', - num_of_removed_rules) % num_of_removed_rules) - else: - report(_('No unused CSS style rules found')) - if remove_unused_classes: + num_changes = num_of_removed_rules + num_merged + num_of_removed_classes + if num_changes > 0: + if num_of_removed_rules > 0: + report(ngettext('Removed %d unused CSS style rule', 'Removed %d unused CSS style rules', + num_of_removed_rules) % num_of_removed_rules) if num_of_removed_classes > 0: report(ngettext('Removed %d unused class from the HTML', 'Removed %d unused classes from the HTML', num_of_removed_classes) % num_of_removed_classes) - else: - report(_('No unused class attributes found')) - return num_of_removed_rules + num_of_removed_classes > 0 + if num_merged > 0: + report(ngettext('Merged %d CSS style rule', 'Merged %d CSS style rules', + num_merged) % num_merged) + if num_of_removed_rules == 0: + report(_('No unused CSS style rules found')) + if remove_unused_classes and num_of_removed_classes == 0: + report(_('No unused class attributes found')) + if merge_rules and num_merged == 0: + report(_('No style rules that could be merged found')) + return num_changes > 0 def filter_declaration(style, properties=()): changed = False diff --git a/src/calibre/ebooks/oeb/polish/main.py b/src/calibre/ebooks/oeb/polish/main.py index 5c889b9a86..95a0f1df0f 100644 --- a/src/calibre/ebooks/oeb/polish/main.py +++ b/src/calibre/ebooks/oeb/polish/main.py @@ -37,6 +37,7 @@ ALL_OPTS = { CUSTOMIZATION = { 'remove_unused_classes': False, + 'merge_identical_selectors': False, } SUPPORTED = {'EPUB', 'AZW3'} @@ -201,7 +202,8 @@ def polish_one(ebook, opts, report, customization=None): if opts.remove_unused_css: rt(_('Removing unused CSS rules')) - if remove_unused_css(ebook, report, remove_unused_classes=customization['remove_unused_classes']): + if remove_unused_css( + ebook, report, remove_unused_classes=customization['remove_unused_classes'], merge_rules=customization['merge_identical_selectors']): changed = True report('') diff --git a/src/calibre/gui2/tweak_book/__init__.py b/src/calibre/gui2/tweak_book/__init__.py index a9d8da43df..f29fb25a72 100644 --- a/src/calibre/gui2/tweak_book/__init__.py +++ b/src/calibre/gui2/tweak_book/__init__.py @@ -49,6 +49,7 @@ d['spell_check_case_sensitive_sort'] = False d['inline_spell_check'] = True d['custom_themes'] = {} d['remove_unused_classes'] = False +d['merge_identical_selectors'] = False d['global_book_toolbar'] = [ 'new-file', 'open-book', 'save-book', None, 'global-undo', 'global-redo', 'create-checkpoint', None, 'donate', 'user-manual'] d['global_tools_toolbar'] = [ diff --git a/src/calibre/gui2/tweak_book/polish.py b/src/calibre/gui2/tweak_book/polish.py index 9a2278521b..be8cc4631b 100644 --- a/src/calibre/gui2/tweak_book/polish.py +++ b/src/calibre/gui2/tweak_book/polish.py @@ -29,14 +29,25 @@ def customize_remove_unused_css(name, parent, ans): d.l = l = QVBoxLayout() d.setLayout(d.l) d.setWindowTitle(_('Remove unused CSS')) - d.la = la = QLabel(_( - 'This will remove all CSS rules that do not match any actual content. You' - ' can also have it automatically remove any class attributes from the HTML' - ' that do not match any CSS rules, by using the check box below:')) - la.setWordWrap(True), l.addWidget(la) + def label(text): + la = QLabel(text) + la.setWordWrap(True), l.addWidget(la), la.setMinimumWidth(450) + l.addWidget(la) + return la + + d.la = label(_( + 'This will remove all CSS rules that do not match any actual content.' + ' There are a couple of additional cleanups you can enable, below:')) d.c = c = QCheckBox(_('Remove unused &class attributes')) c.setChecked(tprefs['remove_unused_classes']) l.addWidget(c) + d.la2 = label('' + _( + 'Remove all class attributes from the HTML that do not match any existing CSS rules')) + d.m = m = QCheckBox(_('Merge identical CSS rules')) + m.setChecked(tprefs['merge_identical_selectors']) + l.addWidget(m) + d.la3 = label('' + _( + 'Merge CSS rules in the same stylesheet that have identical selectors.')) d.bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) d.l.addWidget(d.bb) d.bb.rejected.connect(d.reject) @@ -44,6 +55,7 @@ def customize_remove_unused_css(name, parent, ans): if d.exec_() != d.Accepted: raise Abort() ans['remove_unused_classes'] = tprefs['remove_unused_classes'] = c.isChecked() + ans['merge_identical_selectors'] = tprefs['merge_identical_selectors'] = m.isChecked() def get_customization(action, name, parent): ans = CUSTOMIZATION.copy()