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.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

View File

@ -6,6 +6,8 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__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.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

View File

@ -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):

View File

@ -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 <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):
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('<p>' + _(
''' 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([])

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.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):