mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Kobo driver: Allow specifying per-device model CSS
This commit is contained in:
parent
db7c986001
commit
92a801a71e
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user