mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-08-07 09:01:38 -04:00
Edit book: Remove unused CSS: Add an option to also remove unused class attributes from the HTML
Edit Book: Remove unused CSS: Handle @import rules when checking for unused CSS
This commit is contained in:
parent
7396cb76d5
commit
eb5889b92f
@ -9,8 +9,10 @@ __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
from cssselect import HTMLTranslator
|
from cssutils.css import CSSRule
|
||||||
|
from cssselect import HTMLTranslator, parse
|
||||||
from cssselect.xpath import XPathExpr, is_safe_name
|
from cssselect.xpath import XPathExpr, is_safe_name
|
||||||
|
from cssselect.parser import SelectorSyntaxError
|
||||||
|
|
||||||
from calibre import force_unicode
|
from calibre import force_unicode
|
||||||
from calibre.ebooks.oeb.base import OEB_STYLES, OEB_DOCS, XPNSMAP, XHTML_NS
|
from calibre.ebooks.oeb.base import OEB_STYLES, OEB_DOCS, XPNSMAP, XHTML_NS
|
||||||
@ -118,8 +120,21 @@ def preserve_htmlns_prefix(sheet, prefix):
|
|||||||
else:
|
else:
|
||||||
sheet.namespaces[prefix] = XHTML_NS
|
sheet.namespaces[prefix] = XHTML_NS
|
||||||
|
|
||||||
def remove_unused_css(container, report):
|
def get_imported_sheets(name, container, sheets, recursion_level=10, sheet=None):
|
||||||
from cssutils.css import CSSRule
|
ans = set()
|
||||||
|
sheet = sheet or sheets[name]
|
||||||
|
for rule in sheet.cssRules.rulesOfType(CSSRule.IMPORT_RULE):
|
||||||
|
if rule.href:
|
||||||
|
iname = container.href_to_name(rule.href, name)
|
||||||
|
if iname in sheets:
|
||||||
|
ans.add(iname)
|
||||||
|
if recursion_level > 0:
|
||||||
|
for imported_sheet in tuple(ans):
|
||||||
|
ans |= get_imported_sheets(imported_sheet, container, sheets, recursion_level=recursion_level-1)
|
||||||
|
ans.discard(name)
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def remove_unused_css(container, report, remove_unused_classes=False):
|
||||||
def safe_parse(name):
|
def safe_parse(name):
|
||||||
try:
|
try:
|
||||||
return container.parsed(name)
|
return container.parsed(name)
|
||||||
@ -127,13 +142,16 @@ def remove_unused_css(container, report):
|
|||||||
pass
|
pass
|
||||||
sheets = {name:safe_parse(name) for name, mt in container.mime_map.iteritems() if mt in OEB_STYLES}
|
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}
|
sheets = {k:v for k, v in sheets.iteritems() if v is not None}
|
||||||
|
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()}
|
||||||
sheet_namespace = {}
|
sheet_namespace = {}
|
||||||
for sheet in sheets.itervalues():
|
for sheet in sheets.itervalues():
|
||||||
sheet_namespace[sheet] = process_namespaces(sheet)
|
sheet_namespace[sheet] = process_namespaces(sheet)
|
||||||
sheet.namespaces['h'] = XHTML_NS
|
sheet.namespaces['h'] = XHTML_NS
|
||||||
style_rules = {name:tuple(sheet.cssRules.rulesOfType(CSSRule.STYLE_RULE)) for name, sheet in sheets.iteritems()}
|
style_rules = {name:tuple(sheet.cssRules.rulesOfType(CSSRule.STYLE_RULE)) for name, sheet in sheets.iteritems()}
|
||||||
|
|
||||||
num_of_removed_rules = 0
|
num_of_removed_rules = num_of_removed_classes = 0
|
||||||
pseudo_pat = re.compile(r':(first-letter|first-line|link|hover|visited|active|focus|before|after)', re.I)
|
pseudo_pat = re.compile(r':(first-letter|first-line|link|hover|visited|active|focus|before|after)', re.I)
|
||||||
cache = {}
|
cache = {}
|
||||||
|
|
||||||
@ -141,9 +159,17 @@ def remove_unused_css(container, report):
|
|||||||
if mt not in OEB_DOCS:
|
if mt not in OEB_DOCS:
|
||||||
continue
|
continue
|
||||||
root = container.parsed(name)
|
root = container.parsed(name)
|
||||||
|
used_classes = set()
|
||||||
for style in root.xpath('//*[local-name()="style"]'):
|
for style in root.xpath('//*[local-name()="style"]'):
|
||||||
if style.get('type', 'text/css') == 'text/css' and style.text:
|
if style.get('type', 'text/css') == 'text/css' and style.text:
|
||||||
sheet = container.parse_css(style.text)
|
sheet = container.parse_css(style.text)
|
||||||
|
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)
|
||||||
|
for imported_sheet in imports:
|
||||||
|
style_rules[imported_sheet] = tuple(filter_used_rules(root, style_rules[imported_sheet], container.log, pseudo_pat, cache))
|
||||||
|
if remove_unused_classes:
|
||||||
|
used_classes |= class_map[imported_sheet]
|
||||||
ns = process_namespaces(sheet)
|
ns = process_namespaces(sheet)
|
||||||
sheet.namespaces['h'] = XHTML_NS
|
sheet.namespaces['h'] = XHTML_NS
|
||||||
rules = tuple(sheet.cssRules.rulesOfType(CSSRule.STYLE_RULE))
|
rules = tuple(sheet.cssRules.rulesOfType(CSSRule.STYLE_RULE))
|
||||||
@ -160,6 +186,27 @@ def remove_unused_css(container, report):
|
|||||||
sname = container.href_to_name(link.get('href'), name)
|
sname = container.href_to_name(link.get('href'), name)
|
||||||
if sname in sheets:
|
if sname in sheets:
|
||||||
style_rules[sname] = tuple(filter_used_rules(root, style_rules[sname], container.log, pseudo_pat, cache))
|
style_rules[sname] = tuple(filter_used_rules(root, style_rules[sname], container.log, pseudo_pat, cache))
|
||||||
|
if remove_unused_classes:
|
||||||
|
used_classes |= class_map[sname]
|
||||||
|
|
||||||
|
for iname in import_map[sname]:
|
||||||
|
style_rules[iname] = tuple(filter_used_rules(root, style_rules[iname], container.log, pseudo_pat, cache))
|
||||||
|
if remove_unused_classes:
|
||||||
|
used_classes |= class_map[iname]
|
||||||
|
|
||||||
|
if remove_unused_classes:
|
||||||
|
for elem in root.xpath('//*[@class]'):
|
||||||
|
original_classes, classes = elem.get('class', '').split(), []
|
||||||
|
for x in original_classes:
|
||||||
|
if icu_lower(x) in used_classes:
|
||||||
|
classes.append(x)
|
||||||
|
if len(classes) != len(original_classes):
|
||||||
|
if classes:
|
||||||
|
elem.set('class', ' '.join(classes))
|
||||||
|
else:
|
||||||
|
del elem.attrib['class']
|
||||||
|
num_of_removed_classes += len(original_classes) - len(classes)
|
||||||
|
container.dirty(name)
|
||||||
|
|
||||||
for name, sheet in sheets.iteritems():
|
for name, sheet in sheets.iteritems():
|
||||||
preserve_htmlns_prefix(sheet, sheet_namespace[sheet])
|
preserve_htmlns_prefix(sheet, sheet_namespace[sheet])
|
||||||
@ -170,10 +217,17 @@ def remove_unused_css(container, report):
|
|||||||
container.dirty(name)
|
container.dirty(name)
|
||||||
|
|
||||||
if num_of_removed_rules > 0:
|
if num_of_removed_rules > 0:
|
||||||
report(_('Removed %d unused CSS style rules') % num_of_removed_rules)
|
report(ngettext('Removed %d unused CSS style rule', 'Removed %d unused CSS style rules',
|
||||||
|
num_of_removed_rules) % num_of_removed_rules)
|
||||||
else:
|
else:
|
||||||
report(_('No unused CSS style rules found'))
|
report(_('No unused CSS style rules found'))
|
||||||
return num_of_removed_rules > 0
|
if remove_unused_classes:
|
||||||
|
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
|
||||||
|
|
||||||
def filter_declaration(style, properties):
|
def filter_declaration(style, properties):
|
||||||
changed = False
|
changed = False
|
||||||
@ -251,3 +305,30 @@ def filter_css(container, properties, names=()):
|
|||||||
|
|
||||||
return doc_changed
|
return doc_changed
|
||||||
|
|
||||||
|
def _classes_in_selector(selector, classes):
|
||||||
|
for attr in ('selector', 'subselector', 'parsed_tree'):
|
||||||
|
s = getattr(selector, attr, None)
|
||||||
|
if s is not None:
|
||||||
|
_classes_in_selector(s, classes)
|
||||||
|
cn = getattr(selector, 'class_name', None)
|
||||||
|
if cn is not None:
|
||||||
|
classes.add(cn)
|
||||||
|
|
||||||
|
def classes_in_selector(text):
|
||||||
|
classes = set()
|
||||||
|
try:
|
||||||
|
for selector in parse(text):
|
||||||
|
_classes_in_selector(selector, classes)
|
||||||
|
except SelectorSyntaxError:
|
||||||
|
pass
|
||||||
|
return classes
|
||||||
|
|
||||||
|
def classes_in_rule_list(css_rules):
|
||||||
|
classes = set()
|
||||||
|
for rule in css_rules:
|
||||||
|
if rule.type == rule.STYLE_RULE:
|
||||||
|
classes |= classes_in_selector(rule.selectorText)
|
||||||
|
elif hasattr(rule, 'cssRules'):
|
||||||
|
classes |= classes_in_rule_list(rule.cssRules)
|
||||||
|
return classes
|
||||||
|
|
||||||
|
@ -33,6 +33,10 @@ ALL_OPTS = {
|
|||||||
'remove_unused_css':False,
|
'remove_unused_css':False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CUSTOMIZATION = {
|
||||||
|
'remove_unused_classes': False,
|
||||||
|
}
|
||||||
|
|
||||||
SUPPORTED = {'EPUB', 'AZW3'}
|
SUPPORTED = {'EPUB', 'AZW3'}
|
||||||
|
|
||||||
# Help {{{
|
# Help {{{
|
||||||
@ -129,10 +133,11 @@ def update_metadata(ebook, new_opf):
|
|||||||
stream.truncate()
|
stream.truncate()
|
||||||
stream.write(opf.render())
|
stream.write(opf.render())
|
||||||
|
|
||||||
def polish_one(ebook, opts, report):
|
def polish_one(ebook, opts, report, customization=None):
|
||||||
rt = lambda x: report('\n### ' + x)
|
rt = lambda x: report('\n### ' + x)
|
||||||
jacket = None
|
jacket = None
|
||||||
changed = False
|
changed = False
|
||||||
|
customization = customization or CUSTOMIZATION.copy()
|
||||||
|
|
||||||
if opts.subset or opts.embed:
|
if opts.subset or opts.embed:
|
||||||
stats = StatsCollector(ebook, do_embed=opts.embed)
|
stats = StatsCollector(ebook, do_embed=opts.embed)
|
||||||
@ -194,7 +199,7 @@ def polish_one(ebook, opts, report):
|
|||||||
|
|
||||||
if opts.remove_unused_css:
|
if opts.remove_unused_css:
|
||||||
rt(_('Removing unused CSS rules'))
|
rt(_('Removing unused CSS rules'))
|
||||||
if remove_unused_css(ebook, report):
|
if remove_unused_css(ebook, report, remove_unused_classes=customization['remove_unused_classes']):
|
||||||
changed = True
|
changed = True
|
||||||
report('')
|
report('')
|
||||||
|
|
||||||
@ -233,13 +238,13 @@ def gui_polish(data):
|
|||||||
log(msg)
|
log(msg)
|
||||||
return '\n\n'.join(report)
|
return '\n\n'.join(report)
|
||||||
|
|
||||||
def tweak_polish(container, actions):
|
def tweak_polish(container, actions, customization=None):
|
||||||
opts = ALL_OPTS.copy()
|
opts = ALL_OPTS.copy()
|
||||||
opts.update(actions)
|
opts.update(actions)
|
||||||
O = namedtuple('Options', ' '.join(ALL_OPTS.iterkeys()))
|
O = namedtuple('Options', ' '.join(ALL_OPTS.iterkeys()))
|
||||||
opts = O(**opts)
|
opts = O(**opts)
|
||||||
report = []
|
report = []
|
||||||
changed = polish_one(container, opts, report.append)
|
changed = polish_one(container, opts, report.append, customization=customization)
|
||||||
return report, changed
|
return report, changed
|
||||||
|
|
||||||
def option_parser():
|
def option_parser():
|
||||||
|
@ -48,6 +48,7 @@ d['insert_tag_mru'] = ['p', 'div', 'li', 'h1', 'h2', 'h3', 'h4', 'em', 'strong',
|
|||||||
d['spell_check_case_sensitive_sort'] = False
|
d['spell_check_case_sensitive_sort'] = False
|
||||||
d['inline_spell_check'] = True
|
d['inline_spell_check'] = True
|
||||||
d['custom_themes'] = {}
|
d['custom_themes'] = {}
|
||||||
|
d['remove_unused_classes'] = False
|
||||||
|
|
||||||
del d
|
del d
|
||||||
|
|
||||||
|
@ -11,8 +11,7 @@ from functools import partial, wraps
|
|||||||
|
|
||||||
from PyQt4.Qt import (
|
from PyQt4.Qt import (
|
||||||
QObject, QApplication, QDialog, QGridLayout, QLabel, QSize, Qt,
|
QObject, QApplication, QDialog, QGridLayout, QLabel, QSize, Qt,
|
||||||
QDialogButtonBox, QIcon, QTimer, QPixmap, QTextBrowser, QVBoxLayout,
|
QDialogButtonBox, QIcon, QTimer, QPixmap, QInputDialog)
|
||||||
QInputDialog)
|
|
||||||
|
|
||||||
from calibre import prints, isbytestring
|
from calibre import prints, isbytestring
|
||||||
from calibre.ptempfile import PersistentTemporaryDirectory, TemporaryDirectory
|
from calibre.ptempfile import PersistentTemporaryDirectory, TemporaryDirectory
|
||||||
@ -425,36 +424,22 @@ class Boss(QObject):
|
|||||||
self.edit_file(name, 'html')
|
self.edit_file(name, 'html')
|
||||||
|
|
||||||
def polish(self, action, name, parent=None):
|
def polish(self, action, name, parent=None):
|
||||||
|
from calibre.gui2.tweak_book.polish import get_customization, show_report
|
||||||
|
customization = get_customization(action, name, parent or self.gui)
|
||||||
|
if customization is None:
|
||||||
|
return
|
||||||
with BusyCursor():
|
with BusyCursor():
|
||||||
self.add_savepoint(_('Before: %s') % name)
|
self.add_savepoint(_('Before: %s') % name)
|
||||||
try:
|
try:
|
||||||
report, changed = tweak_polish(current_container(), {action:True})
|
report, changed = tweak_polish(current_container(), {action:True}, customization=customization)
|
||||||
except:
|
except:
|
||||||
self.rewind_savepoint()
|
self.rewind_savepoint()
|
||||||
raise
|
raise
|
||||||
if changed:
|
if changed:
|
||||||
self.apply_container_update_to_gui()
|
self.apply_container_update_to_gui()
|
||||||
from calibre.ebooks.markdown import markdown
|
|
||||||
report = markdown('# %s\n\n'%self.current_metadata.title + '\n\n'.join(report), output_format='html4')
|
|
||||||
if not changed:
|
if not changed:
|
||||||
self.rewind_savepoint()
|
self.rewind_savepoint()
|
||||||
d = QDialog(parent or self.gui)
|
show_report(changed, self.current_metadata.title, report, parent or self.gui, self.show_current_diff)
|
||||||
d.l = QVBoxLayout()
|
|
||||||
d.setLayout(d.l)
|
|
||||||
d.e = QTextBrowser(d)
|
|
||||||
d.l.addWidget(d.e)
|
|
||||||
d.e.setHtml(report)
|
|
||||||
d.bb = QDialogButtonBox(QDialogButtonBox.Close)
|
|
||||||
if changed:
|
|
||||||
b = d.b = d.bb.addButton(_('See what &changed'), d.bb.AcceptRole)
|
|
||||||
b.setIcon(QIcon(I('diff.png'))), b.setAutoDefault(False)
|
|
||||||
b.clicked.connect(partial(self.show_current_diff, allow_revert=True))
|
|
||||||
d.bb.button(d.bb.Close).setDefault(True)
|
|
||||||
d.l.addWidget(d.bb)
|
|
||||||
d.bb.rejected.connect(d.reject)
|
|
||||||
d.bb.accepted.connect(d.accept)
|
|
||||||
d.resize(600, 400)
|
|
||||||
d.exec_()
|
|
||||||
|
|
||||||
def manage_fonts(self):
|
def manage_fonts(self):
|
||||||
self.commit_all_editors_to_container()
|
self.commit_all_editors_to_container()
|
||||||
|
72
src/calibre/gui2/tweak_book/polish.py
Normal file
72
src/calibre/gui2/tweak_book/polish.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# vim:fileencoding=utf-8
|
||||||
|
from __future__ import (unicode_literals, division, absolute_import,
|
||||||
|
print_function)
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from PyQt4.Qt import (
|
||||||
|
QTextBrowser, QVBoxLayout, QDialog, QDialogButtonBox, QIcon, QLabel, QCheckBox)
|
||||||
|
|
||||||
|
from calibre.ebooks.oeb.polish.main import CUSTOMIZATION
|
||||||
|
from calibre.gui2.tweak_book import tprefs
|
||||||
|
|
||||||
|
class Abort(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def customize_remove_unused_css(name, parent, ans):
|
||||||
|
d = QDialog(parent)
|
||||||
|
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)
|
||||||
|
d.c = c = QCheckBox(_('Remove unused &class attributes'))
|
||||||
|
c.setChecked(tprefs['remove_unused_classes'])
|
||||||
|
l.addWidget(c)
|
||||||
|
d.bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
d.l.addWidget(d.bb)
|
||||||
|
d.bb.rejected.connect(d.reject)
|
||||||
|
d.bb.accepted.connect(d.accept)
|
||||||
|
if d.exec_() != d.Accepted:
|
||||||
|
raise Abort()
|
||||||
|
ans['remove_unused_classes'] = tprefs['remove_unused_classes'] = c.isChecked()
|
||||||
|
|
||||||
|
def get_customization(action, name, parent):
|
||||||
|
ans = CUSTOMIZATION.copy()
|
||||||
|
try:
|
||||||
|
if action == 'remove_unused_css':
|
||||||
|
customize_remove_unused_css(name, parent, ans)
|
||||||
|
except Abort:
|
||||||
|
return None
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def format_report(title, report):
|
||||||
|
from calibre.ebooks.markdown import markdown
|
||||||
|
return markdown('# %s\n\n'%title + '\n\n'.join(report), output_format='html4')
|
||||||
|
|
||||||
|
def show_report(changed, title, report, parent, show_current_diff):
|
||||||
|
report = format_report(title, report)
|
||||||
|
d = QDialog(parent)
|
||||||
|
d.l = QVBoxLayout()
|
||||||
|
d.setLayout(d.l)
|
||||||
|
d.e = QTextBrowser(d)
|
||||||
|
d.l.addWidget(d.e)
|
||||||
|
d.e.setHtml(report)
|
||||||
|
d.bb = QDialogButtonBox(QDialogButtonBox.Close)
|
||||||
|
if changed:
|
||||||
|
b = d.b = d.bb.addButton(_('See what &changed'), d.bb.AcceptRole)
|
||||||
|
b.setIcon(QIcon(I('diff.png'))), b.setAutoDefault(False)
|
||||||
|
b.clicked.connect(partial(show_current_diff, allow_revert=True))
|
||||||
|
d.bb.button(d.bb.Close).setDefault(True)
|
||||||
|
d.l.addWidget(d.bb)
|
||||||
|
d.bb.rejected.connect(d.reject)
|
||||||
|
d.bb.accepted.connect(d.accept)
|
||||||
|
d.resize(600, 400)
|
||||||
|
d.exec_()
|
Loading…
x
Reference in New Issue
Block a user