Edit Book: Add a new tool to manage fonts. Allows easily changing/removing/embedding fonts in the entire book. To use it go to Tools->Manage Fonts

This commit is contained in:
Kovid Goyal 2014-06-12 20:46:59 +05:30
parent 621d67273f
commit 99e6287ec1
5 changed files with 243 additions and 18 deletions

View File

@ -13,6 +13,7 @@ from calibre.constants import plugins
from calibre.ebooks.oeb.base import OEB_DOCS, OEB_STYLES 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.check.base import BaseError, WARN
from calibre.ebooks.oeb.polish.container import OEB_FONTS 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.utils import guess_type
from calibre.ebooks.oeb.polish.pretty import pretty_script_or_style from calibre.ebooks.oeb.polish.pretty import pretty_script_or_style
from calibre.utils.fonts.utils import get_all_font_names 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): for i in xrange(ff.length):
val = ff.item(i) val = ff.item(i)
if hasattr(val.value, 'lower') and val.value.lower() == css_name.lower(): if hasattr(val.value, 'lower') and val.value.lower() == css_name.lower():
val.value = font_name change_font_family_value(val, 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'
changed = True changed = True
return changed return changed

View File

@ -6,6 +6,8 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import re
from calibre.ebooks.oeb.polish.container import OEB_STYLES, OEB_DOCS from calibre.ebooks.oeb.polish.container import OEB_STYLES, OEB_DOCS
from calibre.ebooks.oeb.normalize_css import normalize_font from calibre.ebooks.oeb.normalize_css import normalize_font
@ -18,12 +20,12 @@ def font_family_data_from_declaration(style, families):
font_families = [] font_families = []
f = style.getProperty('font') f = style.getProperty('font')
if f is not None: 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: if f is not None:
font_families = f font_families = f
f = style.getProperty('font-family') f = style.getProperty('font-family')
if f is not None: 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: for f in font_families:
f = unquote(f) f = unquote(f)
@ -36,7 +38,7 @@ def font_family_data_from_sheet(sheet, families):
elif rule.type == rule.FONT_FACE_RULE: elif rule.type == rule.FONT_FACE_RULE:
ff = rule.style.getProperty('font-family') ff = rule.style.getProperty('font-family')
if ff is not None: if ff is not None:
for f in ff.cssValue: for f in ff.propertyValue:
families[unquote(f.cssText)] = True families[unquote(f.cssText)] = True
def font_family_data(container): def font_family_data(container):
@ -56,3 +58,101 @@ def font_family_data(container):
style = container.parse_css(style, is_declaration=True) style = container.parse_css(style, is_declaration=True)
font_family_data_from_declaration(style, families) font_family_data_from_declaration(style, families)
return 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

View File

@ -119,6 +119,9 @@ class Boss(QObject):
self.gui.spell_check.word_replaced.connect(self.word_replaced) self.gui.spell_check.word_replaced.connect(self.word_replaced)
self.gui.spell_check.word_ignored.connect(self.word_ignored) self.gui.spell_check.word_ignored.connect(self.word_ignored)
self.gui.live_css.goto_declaration.connect(self.goto_style_declaration) 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): def preferences(self):
p = Preferences(self.gui) p = Preferences(self.gui)
@ -421,7 +424,7 @@ class Boss(QObject):
self.apply_container_update_to_gui() self.apply_container_update_to_gui()
self.edit_file(name, 'html') self.edit_file(name, 'html')
def polish(self, action, name): def polish(self, action, name, parent=None):
with BusyCursor(): with BusyCursor():
self.add_savepoint(_('Before: %s') % name) self.add_savepoint(_('Before: %s') % name)
try: try:
@ -435,7 +438,7 @@ class Boss(QObject):
report = markdown('# %s\n\n'%self.current_metadata.title + '\n\n'.join(report), output_format='html4') 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(self.gui) d = QDialog(parent or self.gui)
d.l = QVBoxLayout() d.l = QVBoxLayout()
d.setLayout(d.l) d.setLayout(d.l)
d.e = QTextBrowser(d) d.e = QTextBrowser(d)
@ -453,6 +456,17 @@ class Boss(QObject):
d.resize(600, 400) d.resize(600, 400)
d.exec_() 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 {{{ # Renaming {{{
def rationalize_folders(self): def rationalize_folders(self):

View File

@ -10,13 +10,16 @@ import sys
from PyQt4.Qt import ( from PyQt4.Qt import (
QSplitter, QVBoxLayout, QTableView, QWidget, QLabel, QAbstractTableModel, 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.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 import current_container, set_current_container
from calibre.gui2.tweak_book.widgets import Dialog, BusyCursor from calibre.gui2.tweak_book.widgets import Dialog, BusyCursor
from calibre.utils.icu import primary_sort_key as sort_key from calibre.utils.icu import primary_sort_key as sort_key
from calibre.utils.fonts.scanner import font_scanner
class AllFonts(QAbstractTableModel): class AllFonts(QAbstractTableModel):
@ -59,6 +62,10 @@ class AllFonts(QAbstractTableModel):
except (IndexError, KeyError): except (IndexError, KeyError):
return return
return name if col == 1 else embedded 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): def sort(self, col, order=Qt.AscendingOrder):
sorted_on = (('name' if col == 1 else 'embedded'), 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.do_sort()
self.endResetModel() 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 <b>exists</b> on your computer and can be embedded') if found else _(
'The font %s <b>does not exist</b> 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): class ManageFonts(Dialog):
container_changed = pyqtSignal()
embed_all_fonts = pyqtSignal()
subset_all_fonts = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
Dialog.__init__(self, _('Manage Fonts'), 'manage-fonts', parent=parent) Dialog.__init__(self, _('Manage Fonts'), 'manage-fonts', parent=parent)
def setup_ui(self): def setup_ui(self):
self.setAttribute(Qt.WA_DeleteOnClose, False)
self.l = l = QVBoxLayout(self) self.l = l = QVBoxLayout(self)
self.setLayout(l) self.setLayout(l)
@ -98,14 +160,25 @@ class ManageFonts(Dialog):
s.addWidget(fv), s.addWidget(c) s.addWidget(fv), s.addWidget(c)
self.cb = b = QPushButton(_('&Change selected fonts')) self.cb = b = QPushButton(_('&Change selected fonts'))
b.setIcon(QIcon(I('auto_author_sort.png')))
b.clicked.connect(self.change_fonts) b.clicked.connect(self.change_fonts)
l.addWidget(b) l.addWidget(b)
self.rb = b = QPushButton(_('&Remove selected fonts')) self.rb = b = QPushButton(_('&Remove selected fonts'))
b.clicked.connect(self.remove_fonts) b.clicked.connect(self.remove_fonts)
b.setIcon(QIcon(I('trash.png')))
l.addWidget(b) l.addWidget(b)
self.eb = b = QPushButton(_('&Embed all fonts')) self.eb = b = QPushButton(_('&Embed all fonts'))
b.setIcon(QIcon(I('embed-fonts.png')))
b.clicked.connect(self.embed_fonts) b.clicked.connect(self.embed_fonts)
l.addWidget(b) 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('<p>' + _( self.la = la = QLabel('<p>' + _(
''' All the fonts declared in this book are shown to the left, along with whether they are embedded or not. ''' 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.addWidget(la)
l.setAlignment(Qt.AlignTop | Qt.AlignHCenter) 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): 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): 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): 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__': if __name__ == '__main__':
app = QApplication([]) app = QApplication([])

View File

@ -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.toc import TOCViewer
from calibre.gui2.tweak_book.char_select import CharSelect from calibre.gui2.tweak_book.char_select import CharSelect
from calibre.gui2.tweak_book.live_css import LiveCSS 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.widget import register_text_editor_actions
from calibre.gui2.tweak_book.editor.insert_resource import InsertImage from calibre.gui2.tweak_book.editor.insert_resource import InsertImage
from calibre.utils.icu import character_name from calibre.utils.icu import character_name
@ -228,6 +229,7 @@ class Main(MainWindow):
self.saved_searches = SavedSearches(self) self.saved_searches = SavedSearches(self)
self.image_browser = InsertImage(self, for_browsing=True) self.image_browser = InsertImage(self, for_browsing=True)
self.insert_char = CharSelect(self) self.insert_char = CharSelect(self)
self.manage_fonts = ManageFonts(self)
self.create_actions() self.create_actions()
self.create_toolbars() self.create_toolbars()
@ -353,6 +355,7 @@ class Main(MainWindow):
_('Set Semantics')) _('Set Semantics'))
self.action_filter_css = reg('filter.png', _('&Filter style information'), self.boss.filter_css, 'filter-css', (), self.action_filter_css = reg('filter.png', _('&Filter style information'), self.boss.filter_css, 'filter-css', (),
_('Filter style information')) _('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 # Polish actions
group = _('Polish Book') group = _('Polish Book')
@ -480,6 +483,7 @@ class Main(MainWindow):
tm = e.addMenu(_('Table of Contents')) tm = e.addMenu(_('Table of Contents'))
tm.addAction(self.action_toc) tm.addAction(self.action_toc)
tm.addAction(self.action_inline_toc) tm.addAction(self.action_inline_toc)
e.addAction(self.action_manage_fonts)
e.addAction(self.action_embed_fonts) e.addAction(self.action_embed_fonts)
e.addAction(self.action_subset_fonts) e.addAction(self.action_subset_fonts)
e.addAction(self.action_smarten_punctuation) e.addAction(self.action_smarten_punctuation)
@ -568,7 +572,7 @@ class Main(MainWindow):
a(self.action_help) a(self.action_help)
a = create(_('Polish book tool bar'), 'polish').addAction 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)) a(getattr(self, 'action_' + x))
def create_docks(self): def create_docks(self):