diff --git a/src/calibre/ebooks/oeb/polish/check/fonts.py b/src/calibre/ebooks/oeb/polish/check/fonts.py index 67eee077b6..7c12757bfb 100644 --- a/src/calibre/ebooks/oeb/polish/check/fonts.py +++ b/src/calibre/ebooks/oeb/polish/check/fonts.py @@ -13,6 +13,7 @@ from calibre.constants import plugins from calibre.ebooks.oeb.base import OEB_DOCS, OEB_STYLES from calibre.ebooks.oeb.polish.check.base import BaseError, WARN from calibre.ebooks.oeb.polish.container import OEB_FONTS +from calibre.ebooks.oeb.polish.fonts import change_font_family_value from calibre.ebooks.oeb.polish.utils import guess_type from calibre.ebooks.oeb.polish.pretty import pretty_script_or_style from calibre.utils.fonts.utils import get_all_font_names @@ -31,12 +32,7 @@ def fix_declaration(style, css_name, font_name): for i in xrange(ff.length): val = ff.item(i) if hasattr(val.value, 'lower') and val.value.lower() == css_name.lower(): - val.value = font_name - # If val.type == 'IDENT' cssutils will not serialize the font - # name properly (it will not enclose it in quotes). There we - # use the following hack (setting an internal property of the - # Value class) - val._type = 'STRING' + change_font_family_value(val, font_name) changed = True return changed diff --git a/src/calibre/ebooks/oeb/polish/fonts.py b/src/calibre/ebooks/oeb/polish/fonts.py index 3fa61fbb1e..b5cbc8b78f 100644 --- a/src/calibre/ebooks/oeb/polish/fonts.py +++ b/src/calibre/ebooks/oeb/polish/fonts.py @@ -6,6 +6,8 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2014, Kovid Goyal ' +import re + from calibre.ebooks.oeb.polish.container import OEB_STYLES, OEB_DOCS from calibre.ebooks.oeb.normalize_css import normalize_font @@ -18,12 +20,12 @@ def font_family_data_from_declaration(style, families): font_families = [] f = style.getProperty('font') if f is not None: - f = normalize_font(f.cssValue, font_family_as_list=True).get('font-family', None) + f = normalize_font(f.propertyValue, font_family_as_list=True).get('font-family', None) if f is not None: font_families = f f = style.getProperty('font-family') if f is not None: - font_families = [x.cssText for x in f.cssValue] + font_families = [x.cssText for x in f.propertyValue] for f in font_families: f = unquote(f) @@ -36,7 +38,7 @@ def font_family_data_from_sheet(sheet, families): elif rule.type == rule.FONT_FACE_RULE: ff = rule.style.getProperty('font-family') if ff is not None: - for f in ff.cssValue: + for f in ff.propertyValue: families[unquote(f.cssText)] = True def font_family_data(container): @@ -56,3 +58,101 @@ def font_family_data(container): style = container.parse_css(style, is_declaration=True) font_family_data_from_declaration(style, families) return families + +def change_font_family_value(cssvalue, new_name): + # If cssvalue.type == 'IDENT' cssutils will not serialize the font + # name properly (it will not enclose it in quotes). So we + # use the following hack (setting an internal property of the + # Value class) + cssvalue.value = new_name + cssvalue._type = 'STRING' + +def change_font_family_in_property(style, prop, old_name, new_name=None): + changed = False + families = {unquote(x.cssText) for x in prop.propertyValue} + _dummy_family = 'd7d81cf1-1c8c-4993-b788-e1ab596c0f1f' + if new_name and new_name in families: + new_name = None # new name already exists in this property, so simply remove old_name + for val in prop.propertyValue: + if unquote(val.cssText) == old_name: + change_font_family_value(val, new_name or _dummy_family) + changed = True + if changed and not new_name: + # Remove dummy family, cssutils provides no clean way to do this, so we + # roundtrip via cssText + pat = re.compile(r'''['"]{0,1}%s['"]{0,1}\s*,{0,1}''' % _dummy_family) + repl = pat.sub('', prop.propertyValue.cssText).strip().rstrip(',').strip() + if repl: + prop.propertyValue.cssText = repl + if prop.name == 'font' and not prop.validate(): + style.removeProperty(prop.name) # no families left in font: + else: + style.removeProperty(prop.name) + return changed + +def change_font_in_declaration(style, old_name, new_name=None): + changed = False + for x in ('font', 'font-family'): + prop = style.getProperty(x) + if prop is not None: + changed |= change_font_family_in_property(style, prop, old_name, new_name) + return changed + +def remove_embedded_font(container, sheet, rule, sheet_name): + src = getattr(rule.style.getProperty('src'), 'value') + if src is not None: + if src.startswith('url('): + src = src[4:-1] + sheet.cssRules.remove(rule) + if src: + src = unquote(src) + name = container.href_to_name(src, sheet_name) + if container.has_name(name): + container.remove_item(name) + +def change_font_in_sheet(container, sheet, old_name, new_name, sheet_name): + changed = False + removals = [] + for rule in sheet.cssRules: + if rule.type == rule.STYLE_RULE: + changed |= change_font_in_declaration(rule.style, old_name, new_name) + elif rule.type == rule.FONT_FACE_RULE: + ff = rule.style.getProperty('font-family') + if ff is not None: + families = {unquote(x.cssText) for x in ff.propertyValue} + if old_name in families: + changed = True + removals.append(rule) + for rule in reversed(removals): + remove_embedded_font(container, sheet, rule, sheet_name) + return changed + +def change_font(container, old_name, new_name=None): + changed = False + for name, mt in tuple(container.mime_map.iteritems()): + if mt in OEB_STYLES: + sheet = container.parsed(name) + if change_font_in_sheet(container, sheet, old_name, new_name, name): + container.dirty(name) + changed = True + elif mt in OEB_DOCS: + root = container.parsed(name) + for style in root.xpath('//*[local-name() = "style"]'): + if style.text and style.get('type', 'text/css').lower() == 'text/css': + sheet = container.parse_css(style.text) + if change_font_in_sheet(container, sheet, old_name, new_name, name): + container.dirty(name) + changed = True + for elem in root.xpath('//*[@style]'): + style = elem.get('style', '') + if style: + style = container.parse_css(style, is_declaration=True) + if change_font_in_declaration(style, old_name, new_name): + style = style.cssText.strip().rstrip(';').strip() + if style: + elem.set('style', style) + else: + del elem.attrib['style'] + container.dirty(name) + changed = True + return changed diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index b3ca169765..93551a43fe 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -119,6 +119,9 @@ class Boss(QObject): self.gui.spell_check.word_replaced.connect(self.word_replaced) self.gui.spell_check.word_ignored.connect(self.word_ignored) self.gui.live_css.goto_declaration.connect(self.goto_style_declaration) + self.gui.manage_fonts.container_changed.connect(self.apply_container_update_to_gui) + self.gui.manage_fonts.embed_all_fonts.connect(self.manage_fonts_embed) + self.gui.manage_fonts.subset_all_fonts.connect(self.manage_fonts_subset) def preferences(self): p = Preferences(self.gui) @@ -421,7 +424,7 @@ class Boss(QObject): self.apply_container_update_to_gui() self.edit_file(name, 'html') - def polish(self, action, name): + def polish(self, action, name, parent=None): with BusyCursor(): self.add_savepoint(_('Before: %s') % name) try: @@ -435,7 +438,7 @@ class Boss(QObject): report = markdown('# %s\n\n'%self.current_metadata.title + '\n\n'.join(report), output_format='html4') if not changed: self.rewind_savepoint() - d = QDialog(self.gui) + d = QDialog(parent or self.gui) d.l = QVBoxLayout() d.setLayout(d.l) d.e = QTextBrowser(d) @@ -453,6 +456,17 @@ class Boss(QObject): d.resize(600, 400) d.exec_() + def manage_fonts(self): + self.commit_all_editors_to_container() + self.gui.manage_fonts.display() + + def manage_fonts_embed(self): + self.polish('embed', _('Embed all fonts'), parent=self.gui.manage_fonts) + self.gui.manage_fonts.refresh() + + def manage_fonts_subset(self): + self.polish('subset', _('Subset all fonts'), parent=self.gui.manage_fonts) + # Renaming {{{ def rationalize_folders(self): diff --git a/src/calibre/gui2/tweak_book/manage_fonts.py b/src/calibre/gui2/tweak_book/manage_fonts.py index 152c637a97..530ed51656 100644 --- a/src/calibre/gui2/tweak_book/manage_fonts.py +++ b/src/calibre/gui2/tweak_book/manage_fonts.py @@ -10,13 +10,16 @@ import sys from PyQt4.Qt import ( QSplitter, QVBoxLayout, QTableView, QWidget, QLabel, QAbstractTableModel, - Qt, QApplication, QTimer, QPushButton) + Qt, QApplication, QTimer, QPushButton, pyqtSignal, QFormLayout, QLineEdit, + QIcon) from calibre.ebooks.oeb.polish.container import get_container -from calibre.ebooks.oeb.polish.fonts import font_family_data +from calibre.ebooks.oeb.polish.fonts import font_family_data, change_font +from calibre.gui2 import error_dialog from calibre.gui2.tweak_book import current_container, set_current_container from calibre.gui2.tweak_book.widgets import Dialog, BusyCursor from calibre.utils.icu import primary_sort_key as sort_key +from calibre.utils.fonts.scanner import font_scanner class AllFonts(QAbstractTableModel): @@ -59,6 +62,10 @@ class AllFonts(QAbstractTableModel): except (IndexError, KeyError): return return name if col == 1 else embedded + if role == Qt.TextAlignmentRole: + col = index.column() + if col == 0: + return Qt.AlignHCenter def sort(self, col, order=Qt.AscendingOrder): sorted_on = (('name' if col == 1 else 'embedded'), order == Qt.AscendingOrder) @@ -68,12 +75,67 @@ class AllFonts(QAbstractTableModel): self.do_sort() self.endResetModel() + def data_for_indices(self, indices): + ans = {} + for idx in indices: + try: + name = self.items[idx.row()] + ans[name] = self.font_data[name] + except (IndexError, KeyError): + pass + return ans + +class ChangeFontFamily(Dialog): + + def __init__(self, old_family, embedded_families, parent=None): + self.old_family = old_family + self.local_families = {icu_lower(f) for f in font_scanner.find_font_families()} | { + icu_lower(f) for f in embedded_families} + Dialog.__init__(self, _('Change font'), 'change-font-family', parent=parent) + self.setMinimumWidth(300) + self.resize(self.sizeHint()) + + def setup_ui(self): + self.l = l = QFormLayout(self) + self.setLayout(l) + self.la = la = QLabel(ngettext( + 'Change the font %s to:', 'Change the fonts %s to:', + self.old_family.count(',')+1) % self.old_family) + la.setWordWrap(True) + l.addRow(la) + self._family = f = QLineEdit(self) + l.addRow(_('&New font:'), f) + f.textChanged.connect(self.updated_family) + self.embed_status = e = QLabel('') + e.setWordWrap(True) + l.addRow(e) + l.addRow(self.bb) + + @property + def family(self): + return unicode(self._family.text()) + + def updated_family(self): + family = self.family + found = icu_lower(family) in self.local_families + t = _('The font %s exists on your computer and can be embedded') if found else _( + 'The font %s does not exist on your computer and cannot be embedded') + t = (t % family) if family else '' + self.embed_status.setText(t) + self.resize(self.sizeHint()) + + class ManageFonts(Dialog): + container_changed = pyqtSignal() + embed_all_fonts = pyqtSignal() + subset_all_fonts = pyqtSignal() + def __init__(self, parent=None): Dialog.__init__(self, _('Manage Fonts'), 'manage-fonts', parent=parent) def setup_ui(self): + self.setAttribute(Qt.WA_DeleteOnClose, False) self.l = l = QVBoxLayout(self) self.setLayout(l) @@ -98,14 +160,25 @@ class ManageFonts(Dialog): s.addWidget(fv), s.addWidget(c) self.cb = b = QPushButton(_('&Change selected fonts')) + b.setIcon(QIcon(I('auto_author_sort.png'))) b.clicked.connect(self.change_fonts) l.addWidget(b) self.rb = b = QPushButton(_('&Remove selected fonts')) b.clicked.connect(self.remove_fonts) + b.setIcon(QIcon(I('trash.png'))) l.addWidget(b) self.eb = b = QPushButton(_('&Embed all fonts')) + b.setIcon(QIcon(I('embed-fonts.png'))) b.clicked.connect(self.embed_fonts) l.addWidget(b) + self.sb = b = QPushButton(_('&Subset all fonts')) + b.setIcon(QIcon(I('subset-fonts.png'))) + b.clicked.connect(self.subset_fonts) + l.addWidget(b) + self.refresh_button = b = self.bb.addButton(_('&Refresh'), self.bb.ActionRole) + b.setToolTip(_('Rescan the book for fonts in case you have made changes')) + b.setIcon(QIcon(I('view-refresh.png'))) + b.clicked.connect(self.refresh) self.la = la = QLabel('

' + _( ''' All the fonts declared in this book are shown to the left, along with whether they are embedded or not. @@ -114,16 +187,54 @@ class ManageFonts(Dialog): l.addWidget(la) l.setAlignment(Qt.AlignTop | Qt.AlignHCenter) - QTimer.singleShot(0, m.build) + + def display(self): + if not self.isVisible(): + self.show() + self.raise_() + QTimer.singleShot(0, self.model.build) + + def get_selected_data(self): + ans = self.model.data_for_indices(list(self.fonts_view.selectedIndexes())) + if not ans: + error_dialog(self, _('No fonts selected'), _( + 'No fonts selected, you must first select some fonts in the left panel'), show=True) + return ans def change_fonts(self): - pass + fonts = self.get_selected_data() + if not fonts: + return + d = ChangeFontFamily(', '.join(fonts), {f for f, embedded in self.model.font_data.iteritems() if embedded}, self) + if d.exec_() != d.Accepted: + return + changed = False + new_family = d.family + for font in fonts: + changed |= change_font(current_container(), font, new_family) + if changed: + self.model.build() + self.container_changed.emit() def remove_fonts(self): - pass + fonts = self.get_selected_data() + if not fonts: + return + changed = False + for font in fonts: + changed |= change_font(current_container(), font) + if changed: + self.model.build() + self.container_changed.emit() def embed_fonts(self): - pass + self.embed_all_fonts.emit() + + def subset_fonts(self): + self.subset_all_fonts.emit() + + def refresh(self): + self.model.build() if __name__ == '__main__': app = QApplication([]) diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py index 27602e3fae..42dad7652b 100644 --- a/src/calibre/gui2/tweak_book/ui.py +++ b/src/calibre/gui2/tweak_book/ui.py @@ -34,6 +34,7 @@ from calibre.gui2.tweak_book.search import SavedSearches from calibre.gui2.tweak_book.toc import TOCViewer from calibre.gui2.tweak_book.char_select import CharSelect from calibre.gui2.tweak_book.live_css import LiveCSS +from calibre.gui2.tweak_book.manage_fonts import ManageFonts from calibre.gui2.tweak_book.editor.widget import register_text_editor_actions from calibre.gui2.tweak_book.editor.insert_resource import InsertImage from calibre.utils.icu import character_name @@ -228,6 +229,7 @@ class Main(MainWindow): self.saved_searches = SavedSearches(self) self.image_browser = InsertImage(self, for_browsing=True) self.insert_char = CharSelect(self) + self.manage_fonts = ManageFonts(self) self.create_actions() self.create_toolbars() @@ -353,6 +355,7 @@ class Main(MainWindow): _('Set Semantics')) self.action_filter_css = reg('filter.png', _('&Filter style information'), self.boss.filter_css, 'filter-css', (), _('Filter style information')) + self.action_manage_fonts = reg('font.png', _('Manage &fonts'), self.boss.manage_fonts, 'manage-fonts', (), _('Manage fonts in the book')) # Polish actions group = _('Polish Book') @@ -480,6 +483,7 @@ class Main(MainWindow): tm = e.addMenu(_('Table of Contents')) tm.addAction(self.action_toc) tm.addAction(self.action_inline_toc) + e.addAction(self.action_manage_fonts) e.addAction(self.action_embed_fonts) e.addAction(self.action_subset_fonts) e.addAction(self.action_smarten_punctuation) @@ -568,7 +572,7 @@ class Main(MainWindow): a(self.action_help) a = create(_('Polish book tool bar'), 'polish').addAction - for x in ('embed_fonts', 'subset_fonts', 'smarten_punctuation', 'remove_unused_css'): + for x in ('manage_fonts', 'embed_fonts', 'subset_fonts', 'smarten_punctuation', 'remove_unused_css'): a(getattr(self, 'action_' + x)) def create_docks(self):