Bookshelf: Allow specifying the font to use for spines

This commit is contained in:
Kovid Goyal 2026-01-21 22:55:35 +05:30
parent 781ed74dc9
commit cd24e5d9b9
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 348 additions and 74 deletions

View File

@ -523,7 +523,7 @@ def create_defs():
defs['bookshelf_min_font_multiplier'] = 0.75
defs['bookshelf_max_font_multiplier'] = 1.3
defs['bookshelf_outline_width'] = 0
defs['bookshelf_bold_font'] = True
defs['bookshelf_font'] = {'family': None, 'style': None}
defs['bookshelf_use_custom_colors'] = False
defs['bookshelf_custom_colors'] = {
'light': {}, 'dark': {},

View File

@ -27,6 +27,7 @@ from qt.core import (
QEasingCurve,
QEvent,
QFont,
QFontDatabase,
QFontInfo,
QFontMetricsF,
QIcon,
@ -1482,7 +1483,10 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea):
self.theme: ColorTheme = None
self.palette_changed()
self.base_font_size_pts = QFontInfo(self.font()).pointSizeF()
self.spine_font = self.default_spine_font = QFont(self.font())
self.spine_font.setBold(True)
self.divider_font = QFont(self.spine_font)
self.base_font_size_pts = QFontInfo(self.spine_font).pointSizeF()
self.outline_width = 0
self.min_line_height = self.base_font_size_pts * 1.2
@ -1632,9 +1636,17 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea):
def refresh_settings(self):
'''Refresh the gui and render settings.'''
self.template_inited = False
s = gprefs['bookshelf_font']
if s and s.get('family'):
self.spine_font = QFontDatabase.font(s['family'], s['style'], int(self.base_font_size_pts))
self.spine_font.setPointSizeF(self.base_font_size_pts)
else:
self.spine_font = self.default_spine_font
self.get_sized_font.cache_clear()
self.get_text_metrics.cache_clear()
self.min_font_size = max(0.1, min(gprefs['bookshelf_min_font_multiplier'], 1)) * self.base_font_size_pts
self.max_font_size = max(1, min(gprefs['bookshelf_max_font_multiplier'], 3)) * self.base_font_size_pts
_, fm, _ = self.get_sized_font(self.min_font_size, bold=gprefs['bookshelf_bold_font'])
_, fm, _ = self.get_sized_font(self.min_font_size)
self.outline_width = float(max(0, min(gprefs['bookshelf_outline_width'], 5)))
self.min_line_height = math.ceil(fm.height() + self.outline_width * 2)
self.calculate_shelf_geometry()
@ -1973,25 +1985,24 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea):
painter.restore()
@lru_cache(maxsize=128)
def get_sized_font(self, sz: float = 9, bold: bool = False) -> tuple[QFont, QFontMetricsF, QFontInfo]:
font = QFont(self.font())
def get_sized_font(self, sz: float = 9, for_divider: bool = False) -> tuple[QFont, QFontMetricsF, QFontInfo]:
font = QFont(self.divider_font if for_divider else self.spine_font)
font.setPointSizeF(sz)
font.setBold(bold)
return font, QFontMetricsF(font), QFontInfo(font)
@lru_cache(maxsize=4096)
def get_text_metrics(
self, first_line: str, second_line: str = '', sz: QSize = QSize(), bold: bool = False, allow_wrap: bool = False,
outline_width: float = 0,
self, first_line: str, second_line: str = '', sz: QSize = QSize(), allow_wrap: bool = False,
outline_width: float = 0, for_divider: bool = False,
) -> tuple[str, str, QFont, QFontMetricsF, bool]:
width, height = sz.width(), sz.height()
font, fm, fi = self.get_sized_font(self.base_font_size_pts, bold)
font, fm, fi = self.get_sized_font(self.base_font_size_pts, for_divider=for_divider)
extra_height = outline_width * 2 # stroke width above and below
if allow_wrap and not second_line and first_line and fm.boundingRect(first_line).width() > width and height >= 2 * self.min_line_height:
# rather than reducing font size if there is available space, wrap to two lines
font2, fm2, fi2 = font, fm, fi
while math.ceil(2 * (fm2.height() + extra_height)) > height:
font2, fm2, fi2 = self.get_sized_font(font2.pointSizeF() - 0.5, bold)
font2, fm2, fi2 = self.get_sized_font(font2.pointSizeF() - 0.5)
if fm2.boundingRect(first_line).width() >= width: # two line font size is larger than one line font size
font, fm, fi = font2, fm2, fi2
has_third_line = False
@ -2015,7 +2026,7 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea):
# larger font sizes
if math.ceil(fm.height() + extra_height) < height:
while font.pointSizeF() < self.max_font_size:
q, qm, qi = self.get_sized_font(font.pointSizeF() + 1, bold)
q, qm, qi = self.get_sized_font(font.pointSizeF() + 1)
if math.ceil(qm.height() + extra_height) < height:
font, fm = q, qm
else:
@ -2025,14 +2036,14 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea):
nsz = font.pointSizeF()
if nsz < self.min_font_size and second_line:
return '', '', font, fm, False
font, fm, fi = self.get_sized_font(nsz - 0.5, bold)
font, fm, fi = self.get_sized_font(nsz - 0.5)
# Now reduce the font size as much as needed to fit within width
text = first_line
if second_line and fm.boundingRect(first_line).width() < fm.boundingRect(second_line).width():
text = second_line
while fi.pointSizeF() > self.min_font_size and fm.boundingRect(text).width() > width:
font, fm, fi = self.get_sized_font(font.pointSizeF() - 1, bold)
font, fm, fi = self.get_sized_font(font.pointSizeF() - 1)
if fi.pointSizeF() <= self.min_font_size:
first_line = fm.elidedText(first_line, Qt.TextElideMode.ElideRight, width)
if second_line:
@ -2069,7 +2080,7 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea):
-self.TEXT_MARGIN if text_right else 0,
0,
)
elided_text, _, font, _, _ = self.get_text_metrics(divider.group_name, '', text_rect.size(), bold=True)
elided_text, _, font, _, _ = self.get_text_metrics(divider.group_name, '', text_rect.size(), for_divider=True)
painter.save()
painter.setFont(font)
painter.setPen(self.theme.divider_text_color)
@ -2200,13 +2211,12 @@ class BookshelfView(MomentumScrollMixin, QAbstractScrollArea):
nfl, nsl, font, fm, was_wrapped = self.get_text_metrics(
first_line, second_line, first_rect.transposed().size(), allow_wrap=True,
bold=gprefs['bookshelf_bold_font'], outline_width=self.outline_width)
outline_width=self.outline_width)
if not nfl and not nsl: # two lines dont fit
second_line = ''
first_rect = QRect(rect.left(), first_rect.top(), rect.width(), first_rect.height())
nfl, nsl, font, fm, _ = self.get_text_metrics(
first_line, second_line, first_rect.transposed().size(), bold=gprefs['bookshelf_bold_font'],
outline_width=self.outline_width)
first_line, second_line, first_rect.transposed().size(), outline_width=self.outline_width)
elif was_wrapped:
first_rect, second_rect = calculate_rects(True)
first_line, second_line, = nfl, nsl

View File

@ -9,7 +9,7 @@ import os
from contextlib import suppress
from functools import lru_cache, partial
from qt.core import QApplication, QDialog, QDialogButtonBox, QIcon, QInputDialog, QLabel, Qt, QTabWidget, QTextBrowser, QTimer, QVBoxLayout, pyqtSignal
from qt.core import QDialog, QDialogButtonBox, QIcon, QInputDialog, QLabel, Qt, QTabWidget, QTextBrowser, QTimer, QVBoxLayout, pyqtSignal
from calibre.gui2 import gprefs
from calibre.gui2.dialogs.confirm_delete import confirm
@ -56,8 +56,29 @@ class BookshelfTab(QTabWidget, LazyConfigWidgetBase, Ui_Form):
recount_updated = pyqtSignal(object)
def __init__(self, parent=None):
self.current_font_choice = {}
super().__init__(parent)
def restore_defaults(self):
super().restore_defaults()
self.current_font_choice = gprefs.defaults['bookshelf_font'].copy()
self.update_font_display()
self.populate_custom_color_theme(use_defaults=True)
def update_font_display(self):
text = ''
s = self.current_font_choice
if s.get('family'):
text = s['family'] + ' - ' + (s.get('style') or '')
self.bookshelf_font_display.setText(text)
def initialize(self):
super().initialize()
s = gprefs['bookshelf_font'] or {}
self.current_font_choice = s.copy()
self.update_font_display()
self.populate_custom_color_theme()
def commit(self, *args):
import re
tp = self.opt_bookshelf_spine_size_template.text()
@ -72,6 +93,7 @@ class BookshelfTab(QTabWidget, LazyConfigWidgetBase, Ui_Form):
for k,b in v.items():
d[k] = b.color
gprefs['bookshelf_custom_colors'] = newval
gprefs['bookshelf_font'] = self.current_font_choice.copy()
return super().commit(*args)
def genesis(self, gui):
@ -87,7 +109,6 @@ class BookshelfTab(QTabWidget, LazyConfigWidgetBase, Ui_Form):
r('bookshelf_make_space_for_second_line', gprefs)
r('bookshelf_min_font_multiplier', gprefs)
r('bookshelf_max_font_multiplier', gprefs)
r('bookshelf_bold_font', gprefs)
r('bookshelf_outline_width', gprefs)
r('bookshelf_divider_text_right', gprefs)
@ -178,8 +199,18 @@ different calibre library you use.''').format('{size}', '{random}', '{pages}'))
l.setBuddy(b)
layout.insertRow(r, b, l)
b.color_changed.connect(self.changed_signal)
QApplication.instance().palette_changed.connect(self.populate_custom_color_theme)
self.populate_custom_color_theme()
self.change_font_button.clicked.connect(self.change_font)
def change_font(self):
from calibre.gui2.preferences.look_feel_tabs.font_selection_dialog import FontSelectionDialog
s = self.current_font_choice
d = FontSelectionDialog(family=s.get('family') or '', style=s.get('style') or '', parent=self)
if d.exec() == QDialog.DialogCode.Accepted:
family, style = d.selected_font()
self.current_font_choice = {'family': family, 'style': style}
self.update_font_display()
self.changed_signal.emit()
def lazy_initialize(self):
self.recount_timer.start()
@ -273,13 +304,13 @@ def evaluate(book, context):
'current_selected_color': _('&The current and selected book highlight'),
}
def populate_custom_color_theme(self):
def populate_custom_color_theme(self, use_defaults=False):
from calibre.gui2.library.bookshelf_view import ColorTheme
default = {
'light': ColorTheme.light_theme()._asdict(),
'dark': ColorTheme.dark_theme()._asdict(),
}
configs = gprefs['bookshelf_custom_colors']
configs = (gprefs.defaults if use_defaults else gprefs)['bookshelf_custom_colors']
for theme in default:
for k in self.color_label_map():
b = self.color_buttons[theme][k]

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>626</width>
<height>520</height>
<height>521</height>
</rect>
</property>
<property name="currentIndex">
@ -377,6 +377,26 @@
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_10">
<property name="text">
<string>&amp;Font:</string>
</property>
<property name="buddy">
<cstring>bookshelf_font_display</cstring>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>&amp;Font size:</string>
</property>
<property name="buddy">
<cstring>opt_bookshelf_min_font_multiplier</cstring>
</property>
</widget>
</item>
<item row="5" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_9">
<item>
@ -434,6 +454,38 @@
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_9">
<property name="text">
<string>&amp;Outline</string>
</property>
<property name="buddy">
<cstring>opt_bookshelf_outline_width</cstring>
</property>
</widget>
</item>
<item>
<widget class="QDoubleSpinBox" name="opt_bookshelf_outline_width">
<property name="toolTip">
<string>&lt;p&gt;Set the thickness, in pixels, of the outline (border) around text on the spine. A value of zero means no outline. An outline helps the text standout against the spine, especially when the spine has a colorful cover.</string>
</property>
<property name="specialValueText">
<string>none</string>
</property>
<property name="suffix">
<string> px</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="maximum">
<double>3.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
</item>
</layout>
</item>
<item row="7" column="0" colspan="2">
@ -458,16 +510,6 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="opt_bookshelf_bold_font">
<property name="toolTip">
<string>&lt;p&gt;Use a bold font to draw text on the book spine</string>
</property>
<property name="text">
<string>Use &amp;bold font</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
@ -483,47 +525,26 @@
</item>
</layout>
</item>
<item row="5" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>&amp;Font size:</string>
</property>
<property name="buddy">
<cstring>opt_bookshelf_min_font_multiplier</cstring>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label_9">
<property name="text">
<string>Text &amp;outline:</string>
</property>
<property name="buddy">
<cstring>opt_bookshelf_outline_width</cstring>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QDoubleSpinBox" name="opt_bookshelf_outline_width">
<property name="toolTip">
<string>&lt;p&gt;Set the thickness, in pixels, of the outline (border) around text on the spine. A value of zero means no outline. An outline helps the text standout against the spine, especially when the spine has a colorful cover.</string>
</property>
<property name="specialValueText">
<string>none</string>
</property>
<property name="suffix">
<string> px</string>
</property>
<property name="decimals">
<number>1</number>
</property>
<property name="maximum">
<double>3.000000000000000</double>
</property>
<property name="singleStep">
<double>0.100000000000000</double>
</property>
</widget>
<layout class="QHBoxLayout" name="horizontalLayout_14">
<item>
<widget class="QLineEdit" name="bookshelf_font_display">
<property name="readOnly">
<bool>true</bool>
</property>
<property name="placeholderText">
<string>Use default font</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="change_font_button">
<property name="text">
<string>Change &amp;font</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>

View File

@ -0,0 +1,212 @@
from qt.core import QDialog, QDialogButtonBox, QFontDatabase, QFontInfo, QHBoxLayout, QLabel, QListWidget, QScrollArea, Qt, QVBoxLayout, QWidget, pyqtSignal
class FontSelectionDialog(QDialog):
fontSelected = pyqtSignal(str, str) # family, style
def __init__(self, family: str = '', style: str = '', min_size=8, medium_size=12, max_size=24, parent=None):
super().__init__(parent)
if family:
self.initial_family, self.initial_style = family, style
else:
font = self.font()
fi = QFontInfo(font)
self.initial_family = fi.family()
self.initial_style = fi.styleName()
self.min_size = min_size
self.medium_size = medium_size
self.max_size = max_size
self.setWindowTitle(_('Select font'))
self.setMinimumSize(600, 500)
self._setup_ui()
self._populate_families()
def _setup_ui(self):
main_layout = QVBoxLayout(self)
# Top section: Font families and styles side by side
lists_layout = QHBoxLayout()
# Font families list
families_layout = QVBoxLayout()
families_label = QLabel(_('&Family:'))
self.families_list = QListWidget()
families_label.setBuddy(self.families_list)
self.families_list.currentItemChanged.connect(self._on_family_changed)
families_layout.addWidget(families_label)
families_layout.addWidget(self.families_list)
# Styles list
styles_layout = QVBoxLayout()
styles_label = QLabel(_('&Style:'))
self.styles_list = QListWidget()
styles_label.setBuddy(self.styles_list)
self.styles_list.currentItemChanged.connect(self._on_style_changed)
styles_layout.addWidget(styles_label)
styles_layout.addWidget(self.styles_list)
lists_layout.addLayout(families_layout, 2)
lists_layout.addLayout(styles_layout, 1)
main_layout.addLayout(lists_layout, stretch=2)
# Preview area
preview_group = QWidget()
preview_layout = QVBoxLayout(preview_group)
preview_layout.setContentsMargins(0, 10, 0, 10)
# Scrollable preview area
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setMinimumHeight(200)
preview_container = QWidget()
self.preview_layout = QVBoxLayout(preview_container)
self.preview_layout.setSpacing(10)
self.preview_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Preview labels for different sizes
self.preview_small = QLabel('The quick brown fox jumps over the lazy dog')
self.preview_medium = QLabel(self.preview_small)
self.preview_large = QLabel(self.preview_small)
self.preview_layout.addWidget(self.preview_small)
self.preview_layout.addWidget(self.preview_medium)
self.preview_layout.addWidget(self.preview_large)
scroll_area.setWidget(preview_container)
preview_layout.addWidget(scroll_area)
main_layout.addWidget(preview_group, stretch=1)
# OK/Cancel buttons
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
main_layout.addWidget(button_box)
def _populate_families(self):
'''Populate the families list with smoothly scalable fonts only'''
self.families_list.clear()
# Get all font families
all_families = QFontDatabase.families()
# Filter for smoothly scalable fonts
scalable_families = []
idx = i = 0
for family in all_families:
if QFontDatabase.isSmoothlyScalable(family):
scalable_families.append(family)
if family == self.initial_family:
idx = i
i += 1
scalable_families.sort()
self.families_list.addItems(scalable_families)
# Select the initial item if available
if self.families_list.count() > 0:
self.families_list.setCurrentRow(idx)
self._on_family_changed(self.families_list.currentItem(), None)
def _on_family_changed(self, current, previous):
'''When a family is selected, populate the styles list'''
if not current:
self.styles_list.clear()
return
family = current.text()
self.styles_list.clear()
# Get all styles for this family
styles = QFontDatabase.styles(family)
idx = 0
if family == self.initial_family and self.initial_style in styles:
idx = styles.index(self.initial_style)
self.styles_list.addItems(styles)
# Select first style if available
if self.styles_list.count() > 0:
self.styles_list.setCurrentRow(idx)
self._update_preview()
def _on_style_changed(self, current, previous):
'''Update the preview when style changes'''
self._update_preview()
def _update_preview(self):
'''Update the preview labels with the selected font'''
family_item = self.families_list.currentItem()
style_item = self.styles_list.currentItem()
if not family_item or not style_item:
return
family = family_item.text()
style = style_item.text()
systems = tuple(QFontDatabase.writingSystems(family))
text = ''
for s in systems:
if (t := QFontDatabase.writingSystemSample(s)):
text = t
break
def s(label, sz):
font = QFontDatabase.font(family, style, int(sz))
font.setPointSizeF(sz)
label.setFont(font)
label.setText('')
if label is self.preview_small:
prefix = _('Minimum size:')
elif label is self.preview_medium:
prefix = _('Base size:')
else:
prefix = _('Maximum size:')
label.setText(prefix + ' ' + text)
s(self.preview_small, self.min_size)
s(self.preview_medium, self.medium_size)
s(self.preview_large, self.max_size)
def selected_font(self):
'''Returns the selected font family and style as a tuple'''
family_item = self.families_list.currentItem()
style_item = self.styles_list. currentItem()
if family_item and style_item:
return family_item.text(), style_item.text()
return None, None
def get_font(self, size=None):
'''Returns a QFont object for the selected family and style'''
family, style = self.selected_font()
if family and style:
if size is None:
size = self.medium_size
return QFontDatabase.font(family, style, size)
return None
def accept(self):
'''Override accept to emit signal with selected font'''
family, style = self.selected_font()
if family and style:
self.fontSelected.emit(family, style)
super().accept()
if __name__ == '__main__':
from calibre.gui2 import Application
app = Application()
def show_dialog():
dialog = FontSelectionDialog(min_size=10, medium_size=14, max_size=28)
if dialog.exec() == QDialog.DialogCode.Accepted:
family, style = dialog.selected_font()
selected_font = dialog.get_font(16)
print(f'Selected: {family} - {style}')
print(f'Font: {selected_font. family()} {selected_font.styleName()} {selected_font.pointSize()}pt')
show_dialog()