Kobo driver: Allow specifying per-device model CSS

This commit is contained in:
Kovid Goyal 2025-02-26 15:20:32 +05:30
parent db7c986001
commit 92a801a71e
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
5 changed files with 241 additions and 58 deletions

View File

@ -36,7 +36,10 @@ from calibre.utils.config import Config, ConfigProxy, OptionParser, make_config_
from polyglot.builtins import iteritems, itervalues from polyglot.builtins import iteritems, itervalues
builtin_names = frozenset(p.name for p in builtin_plugins) builtin_names = frozenset(p.name for p in builtin_plugins)
BLACKLISTED_PLUGINS = frozenset({'Marvin XD', 'iOS reader applications'}) BLACKLISTED_PLUGINS = frozenset({
'Marvin XD',
'iOS reader applications',
})
def zip_value(iterable, value): def zip_value(iterable, value):

View File

@ -2,12 +2,22 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
import os import os
from collections import namedtuple from collections import namedtuple
from typing import NamedTuple
from calibre import prints from calibre import prints
from calibre.constants import iswindows from calibre.constants import iswindows
from calibre.customize import Plugin from calibre.customize import Plugin
class ModelMetadata(NamedTuple):
manufacturer_name: str
model_name: str
vendor_id: int
product_id: int
bcd: int
driver_class: type
class OpenPopupMessage: class OpenPopupMessage:
def __init__(self, title='', message='', level='info', skip_dialog_skip_precheck=True): def __init__(self, title='', message='', level='info', skip_dialog_skip_precheck=True):
@ -140,6 +150,11 @@ class DevicePlugin(Plugin):
' GUI displays this as a non-modal popup. Should be an instance of OpenPopupMessage ' ' GUI displays this as a non-modal popup. Should be an instance of OpenPopupMessage '
return return
@classmethod
def model_metadata(self) -> tuple[ModelMetadata, ...]:
' Metadata about all the actual device models this driver supports '
return ()
# Device detection {{{ # Device detection {{{
def test_bcd(self, bcdDevice, bcd): def test_bcd(self, bcdDevice, bcd):
if bcd is None or len(bcd) == 0: if bcd is None or len(bcd) == 0:

View File

@ -22,6 +22,7 @@ from datetime import datetime
from calibre import fsync, prints, strftime from calibre import fsync, prints, strftime
from calibre.constants import DEBUG from calibre.constants import DEBUG
from calibre.devices.interface import ModelMetadata
from calibre.devices.kobo.books import Book, ImageWrapper, KTCollectionsBookList from calibre.devices.kobo.books import Book, ImageWrapper, KTCollectionsBookList
from calibre.devices.mime import mime_type_ext from calibre.devices.mime import mime_type_ext
from calibre.devices.usbms.books import BookList, CollectionsBookList from calibre.devices.usbms.books import BookList, CollectionsBookList
@ -2261,34 +2262,24 @@ class KOBOTOUCH(KOBO):
return None return None
def get_extra_css(self): def get_extra_css(self):
extra_sheet = None css = ''
from css_parser.css import CSSRule sheet = None
self.extra_css_options = {}
if self.modifying_css(): if self.modifying_css():
extra_css_path = os.path.join(self._main_prefix, self.KOBO_EXTRA_CSSFILE) extra_css_path = os.path.join(self._main_prefix, self.KOBO_EXTRA_CSSFILE)
if os.path.exists(extra_css_path): with suppress(FileNotFoundError), open(extra_css_path) as src:
from css_parser import parseFile as cssparseFile css += '\n\n' + src.read()
try: import json
extra_sheet = cssparseFile(extra_css_path) pdcss = json.loads(self.get_pref('per_device_css') or '{}')
debug_print(f'KoboTouch:get_extra_css: Using extra CSS in {extra_css_path} ({len(extra_sheet.cssRules)} rules)') if any_device := pdcss.get('pid=-1', ''):
if len(extra_sheet.cssRules) ==0: css += '\n\n' + any_device
debug_print('KoboTouch:get_extra_css: Extra CSS file has no valid rules. CSS will not be modified.') key = f'pid={self.detected_product_id()}'
extra_sheet = None if device_css := pdcss.get(key, ''):
except Exception as e: css += '\n\n' + device_css
debug_print(f'KoboTouch:get_extra_css: Problem parsing extra CSS file {extra_css_path}') if css:
debug_print(f'KoboTouch:get_extra_css: Exception {e}') from calibre.ebooks.oeb.polish.kepubify import check_if_css_needs_modification
sheet, self.extra_css_options['has_widows_orphans'], self.extra_css_options['has_atpage'] = check_if_css_needs_modification(css)
# create dictionary of features enabled in kobo extra css return css, sheet
self.extra_css_options = {}
if extra_sheet:
# search extra_css for @page rule
self.extra_css_options['has_atpage'] = len(self.get_extra_css_rules(extra_sheet, CSSRule.PAGE_RULE)) > 0
# search extra_css for style rule(s) containing widows or orphans
self.extra_css_options['has_widows_orphans'] = len(self.get_extra_css_rules_widow_orphan(extra_sheet)) > 0
debug_print('KoboTouch:get_extra_css - CSS options:', self.extra_css_options)
return extra_sheet
def get_extra_css_rules(self, sheet, css_rule): def get_extra_css_rules(self, sheet, css_rule):
return list(sheet.cssRules.rulesOfType(css_rule)) return list(sheet.cssRules.rulesOfType(css_rule))
@ -2308,7 +2299,7 @@ class KOBOTOUCH(KOBO):
ext = '.' + name.rpartition('.')[-1].lower() ext = '.' + name.rpartition('.')[-1].lower()
return ext == EPUB_EXT and modify_epub return ext == EPUB_EXT and modify_epub
self.extra_sheet = self.get_extra_css() self.extra_css, self.extra_sheet = self.get_extra_css()
modifiable = {x for x in names if should_modify(x)} modifiable = {x for x in names if should_modify(x)}
self.files_to_rename_to_kepub = set() self.files_to_rename_to_kepub = set()
if modifiable: if modifiable:
@ -2321,7 +2312,7 @@ class KOBOTOUCH(KOBO):
self.report_progress(i / float(len(modifiable)), 'Processing book: {} by {}'.format(mi.title, ' and '.join(mi.authors))) self.report_progress(i / float(len(modifiable)), 'Processing book: {} by {}'.format(mi.title, ' and '.join(mi.authors)))
mi.kte_calibre_name = n mi.kte_calibre_name = n
if self.get_pref('kepubify'): if self.get_pref('kepubify'):
self._kepubify(file, n, mi, self.extra_sheet) self._kepubify(file, n, mi)
else: else:
self._modify_epub(file, mi) self._modify_epub(file, mi)
i += 1 i += 1
@ -2360,17 +2351,19 @@ class KOBOTOUCH(KOBO):
return result return result
def _kepubify(self, path, name, mi, extra_css) -> None: def _kepubify(self, path, name, mi) -> None:
from calibre.ebooks.oeb.polish.kepubify import kepubify_path, make_options from calibre.ebooks.oeb.polish.kepubify import kepubify_path, make_options
debug_print(f'Starting conversion of {mi.title} ({name}) to kepub') debug_print(f'Starting conversion of {mi.title} ({name}) to kepub')
opts = make_options( opts = make_options(
extra_css=extra_css or '', extra_css=self.extra_css or '',
affect_hyphenation=bool(self.get_pref('affect_hyphenation')), affect_hyphenation=bool(self.get_pref('affect_hyphenation')),
disable_hyphenation=bool(self.get_pref('disable_hyphenation')), disable_hyphenation=bool(self.get_pref('disable_hyphenation')),
hyphenation_min_chars=bool(self.get_pref('hyphenation_min_chars')), hyphenation_min_chars=bool(self.get_pref('hyphenation_min_chars')),
hyphenation_min_chars_before=bool(self.get_pref('hyphenation_min_chars_before')), hyphenation_min_chars_before=bool(self.get_pref('hyphenation_min_chars_before')),
hyphenation_min_chars_after=bool(self.get_pref('hyphenation_min_chars_after')), hyphenation_min_chars_after=bool(self.get_pref('hyphenation_min_chars_after')),
hyphenation_limit_lines=bool(self.get_pref('hyphenation_limit_lines')), hyphenation_limit_lines=bool(self.get_pref('hyphenation_limit_lines')),
remove_at_page_rules=self.extra_css_options.get('has_atpage', False),
remove_widows_and_orphans=self.extra_css_options.get('has_widows_orphans', False),
) )
try: try:
kepubify_path(path, outpath=path, opts=opts, allow_overwrite=True) kepubify_path(path, outpath=path, opts=opts, allow_overwrite=True)
@ -3671,6 +3664,7 @@ class KOBOTOUCH(KOBO):
c.add_opt('kepubify', default=True) c.add_opt('kepubify', default=True)
c.add_opt('modify_css', default=False) c.add_opt('modify_css', default=False)
c.add_opt('per_device_css', default='{}')
c.add_opt('override_kobo_replace_existing', default=True) # Overriding the replace behaviour is how the driver has always worked. c.add_opt('override_kobo_replace_existing', default=True) # Overriding the replace behaviour is how the driver has always worked.
c.add_opt('affect_hyphenation', default=False) c.add_opt('affect_hyphenation', default=False)
@ -3695,6 +3689,39 @@ class KOBOTOUCH(KOBO):
cls.opts = opts cls.opts = opts
return opts return opts
@classmethod
def model_metadata(cls) -> tuple[ModelMetadata, ...]:
def m(name, pid, man='Kobo') -> ModelMetadata:
return ModelMetadata(man, name, cls.VENDOR_ID[-1], pid[-1], cls.BCD[-1], cls)
return (
m('Aura', cls.AURA_PRODUCT_ID),
m('Aura Edition 2', cls.AURA_EDITION2_PRODUCT_ID),
m('Aura HD', cls.AURA_HD_PRODUCT_ID),
m('Aura H2O', cls.AURA_H2O_PRODUCT_ID),
m('Aura H2O Edition 2', cls.AURA_H2O_EDITION2_PRODUCT_ID),
m('Aura One', cls.AURA_ONE_PRODUCT_ID),
m('Clara HD', cls.CLARA_HD_PRODUCT_ID),
m('Clara 2E', cls.CLARA_2E_PRODUCT_ID),
m('Clara Black and White', cls.CLARA_BW_PRODUCT_ID),
m('Clara Color', cls.CLARA_COLOR_PRODUCT_ID),
m('Elipsa', cls.ELIPSA_PRODUCT_ID),
m('Elipsa 2E', cls.ELIPSA_2E_PRODUCT_ID),
m('Forma', cls.FORMA_PRODUCT_ID),
m('Glo', cls.GLO_PRODUCT_ID),
m('Glo HD', cls.GLO_HD_PRODUCT_ID),
m('Libra H2O', cls.LIBRA_H2O_PRODUCT_ID),
m('Libra 2', cls.LIBRA2_PRODUCT_ID),
m('Mini', cls.MINI_PRODUCT_ID),
m('Nia', cls.NIA_PRODUCT_ID),
m('Sage', cls.SAGE_PRODUCT_ID),
m('Touch', cls.TOUCH_PRODUCT_ID),
m('Touch 2', cls.TOUCH2_PRODUCT_ID),
m('Shine 5', cls.TOLINO_SHINE_5THGEN_PRODUCT_ID, man='Tolino'),
m('Shine Color', cls.TOLINO_SHINE_COLOR_PRODUCT_ID, man='Tolino'),
m('Vision Color', cls.TOLINO_VISION_COLOR_PRODUCT_ID, man='Tolino'),
)
def is2024Device(self): def is2024Device(self):
return self.detected_device.idProduct in self.LIBRA_COLOR_PRODUCT_ID return self.detected_device.idProduct in self.LIBRA_COLOR_PRODUCT_ID
@ -3771,6 +3798,21 @@ class KOBOTOUCH(KOBO):
def isShineColor(self): def isShineColor(self):
return self.device_model_id.endswith('693') or self.detected_device.idProduct in self.TOLINO_SHINE_COLOR_PRODUCT_ID return self.device_model_id.endswith('693') or self.detected_device.idProduct in self.TOLINO_SHINE_COLOR_PRODUCT_ID
def detected_product_id(self):
ans = self.detected_device.idProduct
if ans in self.LIBRA_COLOR_PRODUCT_ID:
mid = self.device_model_id[-3:]
match mid:
case '391':
ans = self.CLARA_BW_PRODUCT_ID[-1]
case '393':
ans = self.CLARA_COLOR_PRODUCT_ID[-1]
case '691':
ans = self.TOLINO_SHINE_5THGEN_PRODUCT_ID[-1]
case '693':
ans = self.TOLINO_SHINE_COLOR_PRODUCT_ID[-1]
return ans
def isTouch(self): def isTouch(self):
return self.detected_device.idProduct in self.TOUCH_PRODUCT_ID return self.detected_device.idProduct in self.TOUCH_PRODUCT_ID

View File

@ -4,10 +4,29 @@ __license__ = 'GPL v3'
__copyright__ = '2015-2019, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2015-2019, Kovid Goyal <kovid at kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
import json
import textwrap import textwrap
from qt.core import QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QVBoxLayout, QWidget from qt.core import (
QCheckBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPlainTextEdit,
QPushButton,
QSpinBox,
Qt,
QVBoxLayout,
QWidget,
)
from calibre.devices.interface import ModelMetadata
from calibre.gui2 import error_dialog from calibre.gui2 import error_dialog
from calibre.gui2.device_drivers.tabbed_device_config import DeviceConfigTab, DeviceOptionsGroupBox, TabbedDeviceConfig from calibre.gui2.device_drivers.tabbed_device_config import DeviceConfigTab, DeviceOptionsGroupBox, TabbedDeviceConfig
from calibre.gui2.dialogs.template_dialog import TemplateDialog from calibre.gui2.dialogs.template_dialog import TemplateDialog
@ -52,10 +71,12 @@ class KOBOTOUCHConfig(TabbedDeviceConfig):
self.tab1 = Tab1Config(self, self.device) self.tab1 = Tab1Config(self, self.device)
self.tab2 = Tab2Config(self, self.device) self.tab2 = Tab2Config(self, self.device)
self.tab3 = Tab3Config(self, self.device) self.tab3 = Tab3Config(self, self.device)
self.tab4 = Tab4Config(self, self.device)
self.addDeviceTab(self.tab1, _('Collections, covers && uploads')) self.addDeviceTab(self.tab1, _('Collections, covers && uploads'))
self.addDeviceTab(self.tab2, _('Metadata, on device && advanced')) self.addDeviceTab(self.tab2, _('Metadata, on device && advanced'))
self.addDeviceTab(self.tab3, _('Hyphenation')) self.addDeviceTab(self.tab3, _('Hyphenation'))
self.addDeviceTab(self.tab4, _('Modify CSS'))
def get_pref(self, key): def get_pref(self, key):
return self.device.get_pref(key) return self.device.get_pref(key)
@ -132,6 +153,7 @@ class KOBOTOUCHConfig(TabbedDeviceConfig):
p['bookstats_timetoread_lower_template'] = self.bookstats_timetoread_lower_template p['bookstats_timetoread_lower_template'] = self.bookstats_timetoread_lower_template
p['modify_css'] = self.modify_css p['modify_css'] = self.modify_css
p['per_device_css'] = self.per_device_css
p['kepubify'] = self.kepubify p['kepubify'] = self.kepubify
p['override_kobo_replace_existing'] = self.override_kobo_replace_existing p['override_kobo_replace_existing'] = self.override_kobo_replace_existing
@ -217,6 +239,108 @@ class Tab3Config(DeviceConfigTab): # {{{
# }}} # }}}
class Tab4Config(DeviceConfigTab): # {{{
def __init__(self, parent, device):
super().__init__(parent)
self.l = l = QVBoxLayout(self)
self.modify_css_options = h = ModifyCSSGroupBox(self, device)
self.addDeviceWidget(h)
l.addWidget(h)
l.addStretch()
def validate(self):
return self.modify_css_options.validate()
# }}}
class ModifyCSSGroupBox(DeviceOptionsGroupBox):
def __init__(self, parent, device):
super().__init__(parent, device)
self.setTitle(_('Modify CSS of books sent to the device'))
self.setCheckable(True)
self.setChecked(device.get_pref('modify_css'))
self.l = l = QVBoxLayout(self)
self.la = la = QLabel(
_('This allows addition of user CSS rules and removal of some CSS. '
'When sending a book, the driver adds the contents of {0} to all stylesheets in the book. '
'This file is searched for in the root folder of the main memory of the device. '
'As well as this, if the file contains settings for "orphans" or "widows", '
'these are removed from all styles in the original stylesheet.').format(device.KOBO_EXTRA_CSSFILE),
)
la.setWordWrap(True)
l.addWidget(la)
self.la2 = la = QLabel(_(
'Additionally, model specific CSS can be specified below:'))
la.setWordWrap(True)
l.addWidget(la)
try:
pdcss = json.loads(device.get_pref('per_device_css') or '{}')
except Exception:
pdcss = {}
self.dev_list = QListWidget(self)
self.css_edit = QPlainTextEdit(self)
self.css_edit.setPlaceholderText(_('Enter the CSS to use for books on this model of device'))
self.css_edit.textChanged.connect(self.css_text_changed)
h = QHBoxLayout()
h.addWidget(self.dev_list), h.addWidget(self.css_edit, stretch=100)
l.addLayout(h)
for mm in [ModelMetadata('', _('All models'), -1, -1, -1, type(device))] + sorted(
device.model_metadata(), key=lambda x: x.model_name.lower()):
css = pdcss.get(f'pid={mm.product_id}', '')
i = QListWidgetItem(mm.model_name, self.dev_list)
i.setData(Qt.ItemDataRole.UserRole, (mm, css or ''))
self.dev_list.setCurrentRow(0)
self.dev_list.currentItemChanged.connect(self.current_device_changed)
self.current_device_changed()
self.clear_button = b = QPushButton(_('&Clear all model specific CSS'))
l.addWidget(b)
b.clicked.connect(self.clear_all_css)
def items(self):
for i in range(self.dev_list.count()):
yield self.dev_list.item(i)
def clear_all_css(self):
for item in self.items():
mm, css = item.data(Qt.ItemDataRole.UserRole)
item.setData(Qt.ItemDataRole.UserRole, (mm, ''))
self.current_device_changed()
def current_device_changed(self):
i = self.dev_list.currentItem()
css = ''
if i is not None:
mm, css = i.data(Qt.ItemDataRole.UserRole)
self.css_edit.setPlainText(css or '')
def css_text_changed(self):
i = self.dev_list.currentItem()
if i is not None:
mm, css = i.data(Qt.ItemDataRole.UserRole)
css = self.css_edit.toPlainText().strip()
i.setData(Qt.ItemDataRole.UserRole, (mm, css))
def validate(self):
return True
@property
def modify_css(self):
return self.isChecked()
@property
def per_device_css(self):
ans = {}
for item in self.items():
mm, css = item.data(Qt.ItemDataRole.UserRole)
if css:
ans[f'pid={mm.product_id}'] = css
return json.dumps(ans)
class BookUploadsGroupBox(DeviceOptionsGroupBox): class BookUploadsGroupBox(DeviceOptionsGroupBox):
def __init__(self, parent, device): def __init__(self, parent, device):
@ -235,15 +359,6 @@ class BookUploadsGroupBox(DeviceOptionsGroupBox):
' the Kobo viewer. If you would rather use the legacy viewer for EPUB, disable this option.' ' the Kobo viewer. If you would rather use the legacy viewer for EPUB, disable this option.'
), device.get_pref('kepubify')) ), device.get_pref('kepubify'))
self.modify_css_checkbox = create_checkbox(
_('Modify CSS'),
_('This allows addition of user CSS rules and removal of some CSS. '
'When sending a book, the driver adds the contents of {0} to all stylesheets in the book. '
'This file is searched for in the root folder of the main memory of the device. '
'As well as this, if the file contains settings for the "orphans" or "widows", '
'these are removed for all styles in the original stylesheet.').format(device.KOBO_EXTRA_CSSFILE),
device.get_pref('modify_css')
)
self.override_kobo_replace_existing_checkbox = create_checkbox( self.override_kobo_replace_existing_checkbox = create_checkbox(
_('Do not treat replacements as new books'), _('Do not treat replacements as new books'),
_('When a new book is side-loaded, the Kobo firmware imports details of the book into the internal database. ' _('When a new book is side-loaded, the Kobo firmware imports details of the book into the internal database. '
@ -256,12 +371,7 @@ class BookUploadsGroupBox(DeviceOptionsGroupBox):
) )
self.options_layout.addWidget(self.kepubify_checkbox, 0, 0, 1, 2) self.options_layout.addWidget(self.kepubify_checkbox, 0, 0, 1, 2)
self.options_layout.addWidget(self.modify_css_checkbox, 1, 0, 1, 2) self.options_layout.addWidget(self.override_kobo_replace_existing_checkbox, 1, 0, 1, 2)
self.options_layout.addWidget(self.override_kobo_replace_existing_checkbox, 2, 0, 1, 2)
@property
def modify_css(self):
return self.modify_css_checkbox.isChecked()
@property @property
def override_kobo_replace_existing(self): def override_kobo_replace_existing(self):

View File

@ -551,6 +551,25 @@ def unkepubify_path(path, outpath='', max_workers=0, allow_overwrite=False):
return outpath return outpath
def check_if_css_needs_modification(extra_css: str) -> tuple[bool, bool]:
remove_widows_and_orphans = remove_at_page_rules = False
if extra_css:
try:
sheet = css_parser().parseString(extra_css)
except Exception:
pass
else:
for rule in sheet.cssRules:
if rule.type == CSSRule.PAGE_RULE:
remove_at_page_rules = True
elif rule.type == CSSRule.STYLE_RULE:
if rule.style['widows'] or rule.style['orphans']:
remove_widows_and_orphans = True
if remove_widows_and_orphans and remove_at_page_rules:
break
return sheet, remove_widows_and_orphans, remove_at_page_rules
def make_options( def make_options(
extra_css: str = '', extra_css: str = '',
affect_hyphenation: bool = False, affect_hyphenation: bool = False,
@ -559,18 +578,12 @@ def make_options(
hyphenation_min_chars_before: int = 3, hyphenation_min_chars_before: int = 3,
hyphenation_min_chars_after: int = 3, hyphenation_min_chars_after: int = 3,
hyphenation_limit_lines: int = 2, hyphenation_limit_lines: int = 2,
remove_widows_and_orphans: bool | None = None,
remove_at_page_rules: bool | None = None,
) -> Options: ) -> Options:
remove_widows_and_orphans = remove_at_page_rules = False if remove_widows_and_orphans is None or remove_at_page_rules is None:
if extra_css: _, remove_widows_and_orphans, remove_at_page_rules = check_if_css_needs_modification(extra_css)
sheet = css_parser().parseString(extra_css)
for rule in sheet.cssRules:
if rule.type == CSSRule.PAGE_RULE:
remove_at_page_rules = True
elif rule.type == CSSRule.STYLE_RULE:
if rule.style['widows'] or rule.style['orphans']:
remove_widows_and_orphans = True
if remove_widows_and_orphans and remove_at_page_rules:
break
hyphen_css = '' hyphen_css = ''
if affect_hyphenation: if affect_hyphenation:
if disable_hyphenation: if disable_hyphenation: