diff --git a/src/calibre/customize/ui.py b/src/calibre/customize/ui.py index 752097364f..9756305608 100644 --- a/src/calibre/customize/ui.py +++ b/src/calibre/customize/ui.py @@ -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): diff --git a/src/calibre/devices/interface.py b/src/calibre/devices/interface.py index 22de6f5740..2c3f16d7a1 100644 --- a/src/calibre/devices/interface.py +++ b/src/calibre/devices/interface.py @@ -2,12 +2,22 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' 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: diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index ee4909f1bd..1d8aa7c0a2 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -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 diff --git a/src/calibre/devices/kobo/kobotouch_config.py b/src/calibre/devices/kobo/kobotouch_config.py index 871bb35467..605695c97a 100644 --- a/src/calibre/devices/kobo/kobotouch_config.py +++ b/src/calibre/devices/kobo/kobotouch_config.py @@ -4,10 +4,29 @@ __license__ = 'GPL v3' __copyright__ = '2015-2019, Kovid Goyal ' __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): diff --git a/src/calibre/ebooks/oeb/polish/kepubify.py b/src/calibre/ebooks/oeb/polish/kepubify.py index 1d33793096..1aeb4c96b0 100644 --- a/src/calibre/ebooks/oeb/polish/kepubify.py +++ b/src/calibre/ebooks/oeb/polish/kepubify.py @@ -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: