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

View File

@ -2,12 +2,22 @@ __license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>'
import os
from collections import namedtuple
from typing import NamedTuple
from calibre import prints
from calibre.constants import iswindows
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:
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 '
return
@classmethod
def model_metadata(self) -> tuple[ModelMetadata, ...]:
' Metadata about all the actual device models this driver supports '
return ()
# Device detection {{{
def test_bcd(self, bcdDevice, bcd):
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.constants import DEBUG
from calibre.devices.interface import ModelMetadata
from calibre.devices.kobo.books import Book, ImageWrapper, KTCollectionsBookList
from calibre.devices.mime import mime_type_ext
from calibre.devices.usbms.books import BookList, CollectionsBookList
@ -2261,34 +2262,24 @@ class KOBOTOUCH(KOBO):
return None
def get_extra_css(self):
extra_sheet = None
from css_parser.css import CSSRule
css = ''
sheet = None
self.extra_css_options = {}
if self.modifying_css():
extra_css_path = os.path.join(self._main_prefix, self.KOBO_EXTRA_CSSFILE)
if os.path.exists(extra_css_path):
from css_parser import parseFile as cssparseFile
try:
extra_sheet = cssparseFile(extra_css_path)
debug_print(f'KoboTouch:get_extra_css: Using extra CSS in {extra_css_path} ({len(extra_sheet.cssRules)} rules)')
if len(extra_sheet.cssRules) ==0:
debug_print('KoboTouch:get_extra_css: Extra CSS file has no valid rules. CSS will not be modified.')
extra_sheet = None
except Exception as e:
debug_print(f'KoboTouch:get_extra_css: Problem parsing extra CSS file {extra_css_path}')
debug_print(f'KoboTouch:get_extra_css: Exception {e}')
# create dictionary of features enabled in kobo extra css
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
with suppress(FileNotFoundError), open(extra_css_path) as src:
css += '\n\n' + src.read()
import json
pdcss = json.loads(self.get_pref('per_device_css') or '{}')
if any_device := pdcss.get('pid=-1', ''):
css += '\n\n' + any_device
key = f'pid={self.detected_product_id()}'
if device_css := pdcss.get(key, ''):
css += '\n\n' + device_css
if css:
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)
return css, sheet
def get_extra_css_rules(self, sheet, css_rule):
return list(sheet.cssRules.rulesOfType(css_rule))
@ -2308,7 +2299,7 @@ class KOBOTOUCH(KOBO):
ext = '.' + name.rpartition('.')[-1].lower()
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)}
self.files_to_rename_to_kepub = set()
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)))
mi.kte_calibre_name = n
if self.get_pref('kepubify'):
self._kepubify(file, n, mi, self.extra_sheet)
self._kepubify(file, n, mi)
else:
self._modify_epub(file, mi)
i += 1
@ -2360,17 +2351,19 @@ class KOBOTOUCH(KOBO):
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
debug_print(f'Starting conversion of {mi.title} ({name}) to kepub')
opts = make_options(
extra_css=extra_css or '',
extra_css=self.extra_css or '',
affect_hyphenation=bool(self.get_pref('affect_hyphenation')),
disable_hyphenation=bool(self.get_pref('disable_hyphenation')),
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_after=bool(self.get_pref('hyphenation_min_chars_after')),
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:
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('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('affect_hyphenation', default=False)
@ -3695,6 +3689,39 @@ class KOBOTOUCH(KOBO):
cls.opts = 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):
return self.detected_device.idProduct in self.LIBRA_COLOR_PRODUCT_ID
@ -3771,6 +3798,21 @@ class KOBOTOUCH(KOBO):
def isShineColor(self):
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):
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>'
__docformat__ = 'restructuredtext en'
import json
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.device_drivers.tabbed_device_config import DeviceConfigTab, DeviceOptionsGroupBox, TabbedDeviceConfig
from calibre.gui2.dialogs.template_dialog import TemplateDialog
@ -52,10 +71,12 @@ class KOBOTOUCHConfig(TabbedDeviceConfig):
self.tab1 = Tab1Config(self, self.device)
self.tab2 = Tab2Config(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.tab2, _('Metadata, on device && advanced'))
self.addDeviceTab(self.tab3, _('Hyphenation'))
self.addDeviceTab(self.tab4, _('Modify CSS'))
def get_pref(self, 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['modify_css'] = self.modify_css
p['per_device_css'] = self.per_device_css
p['kepubify'] = self.kepubify
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):
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.'
), 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(
_('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. '
@ -256,12 +371,7 @@ class BookUploadsGroupBox(DeviceOptionsGroupBox):
)
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, 2, 0, 1, 2)
@property
def modify_css(self):
return self.modify_css_checkbox.isChecked()
self.options_layout.addWidget(self.override_kobo_replace_existing_checkbox, 1, 0, 1, 2)
@property
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
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(
extra_css: str = '',
affect_hyphenation: bool = False,
@ -559,18 +578,12 @@ def make_options(
hyphenation_min_chars_before: int = 3,
hyphenation_min_chars_after: int = 3,
hyphenation_limit_lines: int = 2,
remove_widows_and_orphans: bool | None = None,
remove_at_page_rules: bool | None = None,
) -> Options:
remove_widows_and_orphans = remove_at_page_rules = False
if 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
if remove_widows_and_orphans is None or remove_at_page_rules is None:
_, remove_widows_and_orphans, remove_at_page_rules = check_if_css_needs_modification(extra_css)
hyphen_css = ''
if affect_hyphenation:
if disable_hyphenation: