%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/tweak_book/ |
Current File : //lib/calibre/calibre/gui2/tweak_book/widgets.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>' import os import textwrap import unicodedata from collections import OrderedDict from math import ceil from qt.core import ( QAbstractListModel, QApplication, QCheckBox, QComboBox, QCursor, QDialog, QDialogButtonBox, QEvent, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QIcon, QItemSelectionModel, QLabel, QLineEdit, QListView, QMimeData, QModelIndex, QPainter, QPalette, QPixmap, QPlainTextEdit, QPoint, QRect, QSize, QSizePolicy, QSplitter, QStaticText, QStyle, QStyledItemDelegate, Qt, QTextDocument, QTextOption, QToolButton, QVBoxLayout, QWidget, pyqtSignal ) from calibre import human_readable, prepare_string_for_xml from calibre.constants import iswindows from calibre.ebooks.oeb.polish.cover import get_raster_cover_name from calibre.ebooks.oeb.polish.toc import ( ensure_container_has_nav, get_guide_landmarks, get_nav_landmarks, set_landmarks ) from calibre.ebooks.oeb.polish.upgrade import guide_epubtype_map from calibre.ebooks.oeb.polish.utils import guess_type, lead_text from calibre.gui2 import ( choose_files, choose_images, choose_save_file, error_dialog, info_dialog ) from calibre.gui2.complete2 import EditWithComplete from calibre.gui2.tweak_book import current_container, tprefs from calibre.gui2.widgets2 import ( PARAGRAPH_SEPARATOR, Dialog as BaseDialog, HistoryComboBox, to_plain_text ) from calibre.utils.icu import ( numeric_sort_key, primary_contains, primary_sort_key, sort_key ) from calibre.utils.matcher import ( DEFAULT_LEVEL1, DEFAULT_LEVEL2, DEFAULT_LEVEL3, Matcher, get_char ) from polyglot.builtins import iteritems ROOT = QModelIndex() class BusyCursor: def __enter__(self): QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) def __exit__(self, *args): QApplication.restoreOverrideCursor() class Dialog(BaseDialog): def __init__(self, title, name, parent=None): BaseDialog.__init__(self, title, name, parent=parent, prefs=tprefs) class InsertTag(Dialog): # {{{ def __init__(self, parent=None): Dialog.__init__(self, _('Choose tag name'), 'insert-tag', parent=parent) def setup_ui(self): from calibre.ebooks.constants import html5_tags self.l = l = QVBoxLayout(self) self.setLayout(l) self.la = la = QLabel(_('Specify the name of the &tag to insert:')) l.addWidget(la) self.tag_input = ti = EditWithComplete(self) ti.set_separator(None) ti.all_items = html5_tags | frozenset(tprefs['insert_tag_mru']) la.setBuddy(ti) l.addWidget(ti) l.addWidget(self.bb) ti.setFocus(Qt.FocusReason.OtherFocusReason) @property def tag(self): return str(self.tag_input.text()).strip() @classmethod def test(cls): d = cls() if d.exec() == QDialog.DialogCode.Accepted: print(d.tag) # }}} class RationalizeFolders(Dialog): # {{{ TYPE_MAP = ( ('text', _('Text (HTML) files')), ('style', _('Style (CSS) files')), ('image', _('Images')), ('font', _('Fonts')), ('audio', _('Audio')), ('video', _('Video')), ('opf', _('OPF file (metadata)')), ('toc', _('Table of contents file (NCX)')), ) def __init__(self, parent=None): Dialog.__init__(self, _('Arrange in folders'), 'rationalize-folders', parent=parent) def setup_ui(self): self.l = l = QGridLayout() self.setLayout(l) self.la = la = QLabel(_( 'Arrange the files in this book into sub-folders based on their types.' ' If you leave a folder blank, the files will be placed in the root.')) la.setWordWrap(True) l.addWidget(la, 0, 0, 1, -1) folders = tprefs['folders_for_types'] for i, (typ, text) in enumerate(self.TYPE_MAP): la = QLabel('&' + text) setattr(self, '%s_label' % typ, la) le = QLineEdit(self) setattr(self, '%s_folder' % typ, le) val = folders.get(typ, '') if val and not val.endswith('/'): val += '/' le.setText(val) la.setBuddy(le) l.addWidget(la, i + 1, 0) l.addWidget(le, i + 1, 1) self.la2 = la = QLabel(_( 'Note that this will only arrange files inside the book,' ' it will not affect how they are displayed in the File browser')) la.setWordWrap(True) l.addWidget(la, i + 2, 0, 1, -1) l.addWidget(self.bb, i + 3, 0, 1, -1) @property def folder_map(self): ans = {} for typ, x in self.TYPE_MAP: val = str(getattr(self, '%s_folder' % typ).text()).strip().strip('/') ans[typ] = val return ans def accept(self): tprefs['folders_for_types'] = self.folder_map return Dialog.accept(self) # }}} class MultiSplit(Dialog): # {{{ def __init__(self, parent=None): Dialog.__init__(self, _('Specify locations to split at'), 'multisplit-xpath', parent=parent) def setup_ui(self): from calibre.gui2.convert.xpath_wizard import XPathEdit self.l = l = QVBoxLayout(self) self.setLayout(l) self.la = la = QLabel(_( 'Specify the locations to split at, using an XPath expression (click' ' the wizard button for help with generating XPath expressions).')) la.setWordWrap(True) l.addWidget(la) self._xpath = xp = XPathEdit(self) xp.set_msg(_('&XPath expression:')) xp.setObjectName('editor-multisplit-xpath-edit') l.addWidget(xp) l.addWidget(self.bb) def accept(self): if not self._xpath.check(): return error_dialog(self, _('Invalid XPath expression'), _( 'The XPath expression %s is invalid.') % self.xpath) return Dialog.accept(self) @property def xpath(self): return self._xpath.xpath # }}} class ImportForeign(Dialog): # {{{ def __init__(self, parent=None): Dialog.__init__(self, _('Choose file to import'), 'import-foreign') def sizeHint(self): ans = Dialog.sizeHint(self) ans.setWidth(ans.width() + 200) return ans def setup_ui(self): self.l = l = QFormLayout(self) l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.setLayout(l) la = self.la = QLabel(_( 'You can import an HTML or DOCX file directly as an EPUB and edit it. The EPUB' ' will be generated with minimal changes from the source, unlike doing a full' ' conversion in calibre.')) la.setWordWrap(True) l.addRow(la) self.h1 = h1 = QHBoxLayout() self.src = src = QLineEdit(self) src.setPlaceholderText(_('Choose the file to import')) h1.addWidget(src) self.b1 = b = QToolButton(self) b.setIcon(QIcon(I('document_open.png'))) b.setText(_('Choose file')) h1.addWidget(b) l.addRow(_('Source file:'), h1) b.clicked.connect(self.choose_source) b.setFocus(Qt.FocusReason.OtherFocusReason) self.h2 = h1 = QHBoxLayout() self.dest = src = QLineEdit(self) src.setPlaceholderText(_('Choose the location for the newly created EPUB')) h1.addWidget(src) self.b2 = b = QToolButton(self) b.setIcon(QIcon(I('document_open.png'))) b.setText(_('Choose file')) h1.addWidget(b) l.addRow(_('Destination file:'), h1) b.clicked.connect(self.choose_destination) l.addRow(self.bb) def choose_source(self): from calibre.ebooks.oeb.polish.import_book import IMPORTABLE path = choose_files(self, 'edit-book-choose-file-to-import', _('Choose file'), filters=[ (_('Importable files'), list(IMPORTABLE))], select_only_single_file=True) if path: self.set_src(path[0]) def set_src(self, path): self.src.setText(path) self.dest.setText(self.data[1]) def choose_destination(self): path = choose_save_file(self, 'edit-book-destination-for-generated-epub', _('Choose destination'), filters=[ (_('EPUB files'), ['epub'])], all_files=False) if path: if not path.lower().endswith('.epub'): path += '.epub' self.dest.setText(path) def accept(self): if not str(self.src.text()): return error_dialog(self, _('Need document'), _( 'You must specify the source file that will be imported.'), show=True) Dialog.accept(self) @property def data(self): src = str(self.src.text()).strip() dest = str(self.dest.text()).strip() if not dest: dest = src.rpartition('.')[0] + '.epub' return src, dest # }}} # Quick Open {{{ def make_highlighted_text(emph, text, positions): positions = sorted(set(positions) - {-1}) if positions: parts = [] pos = 0 for p in positions: ch = get_char(text, p) parts.append(prepare_string_for_xml(text[pos:p])) parts.append(f'<span style="{emph}">{prepare_string_for_xml(ch)}</span>') pos = p + len(ch) parts.append(prepare_string_for_xml(text[pos:])) return ''.join(parts) return text def emphasis_style(): pal = QApplication.instance().palette() return f'color: {pal.color(QPalette.ColorRole.Link).name()}; font-weight: bold' class Results(QWidget): MARGIN = 4 item_selected = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent=parent) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.results = () self.current_result = -1 self.max_result = -1 self.mouse_hover_result = -1 self.setMouseTracking(True) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.text_option = to = QTextOption() to.setWrapMode(QTextOption.WrapMode.NoWrap) self.divider = QStaticText('\xa0→ \xa0') self.divider.setTextFormat(Qt.TextFormat.PlainText) def item_from_y(self, y): if not self.results: return delta = self.results[0][0].size().height() + self.MARGIN maxy = self.height() pos = 0 for i, r in enumerate(self.results): bottom = pos + delta if pos <= y < bottom: return i break pos = bottom if pos > min(y, maxy): break return -1 def mouseMoveEvent(self, ev): y = ev.pos().y() prev = self.mouse_hover_result self.mouse_hover_result = self.item_from_y(y) if prev != self.mouse_hover_result: self.update() def mousePressEvent(self, ev): if ev.button() == 1: i = self.item_from_y(ev.pos().y()) if i != -1: ev.accept() self.current_result = i self.update() self.item_selected.emit() return return QWidget.mousePressEvent(self, ev) def change_current(self, delta=1): if not self.results: return nc = self.current_result + delta if 0 <= nc <= self.max_result: self.current_result = nc self.update() def __call__(self, results): if results: self.current_result = 0 prefixes = [QStaticText('<b>%s</b>' % os.path.basename(x)) for x in results] [(p.setTextFormat(Qt.TextFormat.RichText), p.setTextOption(self.text_option)) for p in prefixes] self.maxwidth = max(int(ceil(x.size().width())) for x in prefixes) self.results = tuple((prefix, self.make_text(text, positions), text) for prefix, (text, positions) in zip(prefixes, iteritems(results))) else: self.results = () self.current_result = -1 self.max_result = min(10, len(self.results) - 1) self.mouse_hover_result = -1 self.update() def make_text(self, text, positions): text = QStaticText(make_highlighted_text(emphasis_style(), text, positions)) text.setTextOption(self.text_option) text.setTextFormat(Qt.TextFormat.RichText) return text def paintEvent(self, ev): offset = QPoint(0, 0) p = QPainter(self) p.setClipRect(ev.rect()) bottom = self.rect().bottom() if self.results: for i, (prefix, full, text) in enumerate(self.results): size = prefix.size() if offset.y() + size.height() > bottom: break self.max_result = i offset.setX(0) if i in (self.current_result, self.mouse_hover_result): p.save() if i != self.current_result: p.setPen(Qt.PenStyle.DotLine) p.drawLine(offset, QPoint(self.width(), offset.y())) p.restore() offset.setY(offset.y() + self.MARGIN // 2) p.drawStaticText(offset, prefix) offset.setX(self.maxwidth + 5) p.drawStaticText(offset, self.divider) offset.setX(offset.x() + int(ceil(self.divider.size().width()))) p.drawStaticText(offset, full) offset.setY(int(offset.y() + size.height() + self.MARGIN // 2)) if i in (self.current_result, self.mouse_hover_result): offset.setX(0) p.save() if i != self.current_result: p.setPen(Qt.PenStyle.DotLine) p.drawLine(offset, QPoint(self.width(), offset.y())) p.restore() else: p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, _('No results found')) p.end() @property def selected_result(self): try: return self.results[self.current_result][-1] except IndexError: pass class QuickOpen(Dialog): def __init__(self, items, parent=None, title=None, name='quick-open', level1=DEFAULT_LEVEL1, level2=DEFAULT_LEVEL2, level3=DEFAULT_LEVEL3, help_text=None): self.matcher = Matcher(items, level1=level1, level2=level2, level3=level3) self.matches = () self.selected_result = None self.help_text = help_text or self.default_help_text() Dialog.__init__(self, title or _('Choose file to edit'), name, parent=parent) def sizeHint(self): ans = Dialog.sizeHint(self) ans.setWidth(800) ans.setHeight(max(600, ans.height())) return ans def default_help_text(self): example = '<pre>{0}i{1}mages/{0}c{1}hapter1/{0}s{1}cene{0}3{1}.jpg</pre>'.format( '<span style="%s">' % emphasis_style(), '</span>') chars = '<pre style="%s">ics3</pre>' % emphasis_style() return _('''<p>Quickly choose a file by typing in just a few characters from the file name into the field above. For example, if want to choose the file: {example} Simply type in the characters: {chars} and press Enter.''').format(example=example, chars=chars) def setup_ui(self): self.l = l = QVBoxLayout(self) self.setLayout(l) self.text = t = QLineEdit(self) t.textEdited.connect(self.update_matches) t.setClearButtonEnabled(True) t.setPlaceholderText(_('Search')) l.addWidget(t, alignment=Qt.AlignmentFlag.AlignTop) self.help_label = hl = QLabel(self.help_text) hl.setContentsMargins(50, 50, 50, 50), hl.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) l.addWidget(hl) self.results = Results(self) self.results.setVisible(False) self.results.item_selected.connect(self.accept) l.addWidget(self.results) l.addWidget(self.bb, alignment=Qt.AlignmentFlag.AlignBottom) def update_matches(self, text): text = str(text).strip() self.help_label.setVisible(False) self.results.setVisible(True) matches = self.matcher(text, limit=100) self.results(matches) self.matches = tuple(matches) def keyPressEvent(self, ev): if ev.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down): ev.accept() self.results.change_current(delta=-1 if ev.key() == Qt.Key.Key_Up else 1) return return Dialog.keyPressEvent(self, ev) def accept(self): self.selected_result = self.results.selected_result return Dialog.accept(self) @classmethod def test(cls): from calibre.utils.matcher import get_items_from_dir items = get_items_from_dir(os.getcwd(), lambda x:not x.endswith('.pyc')) d = cls(items) d.exec() print(d.selected_result) # }}} # Filterable names list {{{ class NamesDelegate(QStyledItemDelegate): def sizeHint(self, option, index): ans = QStyledItemDelegate.sizeHint(self, option, index) ans.setHeight(ans.height() + 10) return ans def paint(self, painter, option, index): QStyledItemDelegate.paint(self, painter, option, index) text, positions = index.data(Qt.ItemDataRole.UserRole) self.initStyleOption(option, index) painter.save() painter.setFont(option.font) p = option.palette c = QPalette.ColorRole.HighlightedText if option.state & QStyle.StateFlag.State_Selected else QPalette.ColorRole.Text group = (QPalette.ColorGroup.Active if option.state & QStyle.StateFlag.State_Active else QPalette.ColorGroup.Inactive) c = p.color(group, c) painter.setClipRect(option.rect) if positions is None or -1 in positions: painter.setPen(c) painter.drawText(option.rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter | Qt.TextFlag.TextSingleLine, text) else: to = QTextOption() to.setWrapMode(QTextOption.WrapMode.NoWrap) to.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) positions = sorted(set(positions) - {-1}, reverse=True) text = '<body>%s</body>' % make_highlighted_text(emphasis_style(), text, positions) doc = QTextDocument() c = 'rgb(%d, %d, %d)'%c.getRgb()[:3] doc.setDefaultStyleSheet(' body { color: %s }'%c) doc.setHtml(text) doc.setDefaultFont(option.font) doc.setDocumentMargin(0.0) doc.setDefaultTextOption(to) height = doc.size().height() painter.translate(option.rect.left(), option.rect.top() + (max(0, option.rect.height() - height) // 2)) doc.drawContents(painter) painter.restore() class NamesModel(QAbstractListModel): filtered = pyqtSignal(object) def __init__(self, names, parent=None): self.items = [] QAbstractListModel.__init__(self, parent) self.set_names(names) def set_names(self, names): self.names = names self.matcher = Matcher(names) self.filter('') def rowCount(self, parent=ROOT): return len(self.items) def data(self, index, role): if role == Qt.ItemDataRole.UserRole: return self.items[index.row()] if role == Qt.ItemDataRole.DisplayRole: return '\xa0' * 20 def filter(self, query): query = str(query or '') self.beginResetModel() if not query: self.items = tuple((text, None) for text in self.names) else: self.items = tuple(iteritems(self.matcher(query))) self.endResetModel() self.filtered.emit(not bool(query)) def find_name(self, name): for i, (text, positions) in enumerate(self.items): if text == name: return i def name_for_index(self, index): try: return self.items[index.row()][0] except IndexError: pass def create_filterable_names_list(names, filter_text=None, parent=None, model=NamesModel): nl = QListView(parent) nl.m = m = model(names, parent=nl) connect_lambda(m.filtered, nl, lambda nl, all_items: nl.scrollTo(m.index(0))) nl.setModel(m) if model is NamesModel: nl.d = NamesDelegate(nl) nl.setItemDelegate(nl.d) f = QLineEdit(parent) f.setPlaceholderText(filter_text or '') f.textEdited.connect(m.filter) return nl, f # }}} # Insert Link {{{ class AnchorsModel(QAbstractListModel): filtered = pyqtSignal(object) def __init__(self, names, parent=None): self.items = [] self.names = [] QAbstractListModel.__init__(self, parent=parent) def rowCount(self, parent=ROOT): return len(self.items) def data(self, index, role): if role == Qt.ItemDataRole.UserRole: return self.items[index.row()] if role == Qt.ItemDataRole.DisplayRole: return '\n'.join(self.items[index.row()]) if role == Qt.ItemDataRole.ToolTipRole: text, frag = self.items[index.row()] return _('Anchor: {0}\nLeading text: {1}').format(frag, text) def set_names(self, names): self.names = names self.filter('') def filter(self, query): query = str(query or '') self.beginResetModel() self.items = [x for x in self.names if primary_contains(query, x[0]) or primary_contains(query, x[1])] self.endResetModel() self.filtered.emit(not bool(query)) class InsertLink(Dialog): def __init__(self, container, source_name, initial_text=None, parent=None): self.container = container self.source_name = source_name self.initial_text = initial_text Dialog.__init__(self, _('Insert hyperlink'), 'insert-hyperlink', parent=parent) self.anchor_cache = {} def sizeHint(self): return QSize(800, 600) def setup_ui(self): self.l = l = QVBoxLayout(self) self.setLayout(l) self.h = h = QHBoxLayout() l.addLayout(h) names = [n for n, linear in self.container.spine_names] fn, f = create_filterable_names_list(names, filter_text=_('Filter files'), parent=self) self.file_names, self.file_names_filter = fn, f fn.selectionModel().selectionChanged.connect(self.selected_file_changed) self.fnl = fnl = QVBoxLayout() self.la1 = la = QLabel(_('Choose a &file to link to:')) la.setBuddy(fn) fnl.addWidget(la), fnl.addWidget(f), fnl.addWidget(fn) h.addLayout(fnl), h.setStretch(0, 2) fn, f = create_filterable_names_list([], filter_text=_('Filter locations'), parent=self, model=AnchorsModel) fn.setSpacing(5) self.anchor_names, self.anchor_names_filter = fn, f fn.selectionModel().selectionChanged.connect(self.update_target) fn.doubleClicked.connect(self.accept, type=Qt.ConnectionType.QueuedConnection) self.anl = fnl = QVBoxLayout() self.la2 = la = QLabel(_('Choose a &location (anchor) in the file:')) la.setBuddy(fn) fnl.addWidget(la), fnl.addWidget(f), fnl.addWidget(fn) h.addLayout(fnl), h.setStretch(1, 1) self.tl = tl = QFormLayout() tl.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.target = t = QLineEdit(self) t.setPlaceholderText(_('The destination (href) for the link')) tl.addRow(_('&Target:'), t) l.addLayout(tl) self.text_edit = t = QLineEdit(self) la.setBuddy(t) tl.addRow(_('Te&xt:'), t) t.setText(self.initial_text or '') t.setPlaceholderText(_('The (optional) text for the link')) self.template_edit = t = HistoryComboBox(self) t.lineEdit().setClearButtonEnabled(True) t.initialize('edit_book_insert_link_template_history') tl.addRow(_('Tem&plate:'), t) from calibre.gui2.tweak_book.editor.smarts.html import DEFAULT_LINK_TEMPLATE t.setText(tprefs.get('insert-hyperlink-template', None) or DEFAULT_LINK_TEMPLATE) t.setToolTip('<p>' + _(''' The template to use for generating the link. In addition to {0} and {1} you can also use {2}, {3} and {4} variables in the template, they will be replaced by the source filename, the destination filename and the anchor, respectively. ''').format( '_TEXT_', '_TARGET_', '_SOURCE_FILENAME_', '_DEST_FILENAME_', '_ANCHOR_')) l.addWidget(self.bb) def accept(self): from calibre.gui2.tweak_book.editor.smarts.html import DEFAULT_LINK_TEMPLATE t = self.template if t: if t == DEFAULT_LINK_TEMPLATE: t = None tprefs.set('insert-hyperlink-template', self.template) return Dialog.accept(self) def selected_file_changed(self, *args): rows = list(self.file_names.selectionModel().selectedRows()) if not rows: self.anchor_names.model().set_names([]) else: name, positions = self.file_names.model().data(rows[0], Qt.ItemDataRole.UserRole) self.populate_anchors(name) def populate_anchors(self, name): if name not in self.anchor_cache: from calibre.ebooks.oeb.base import XHTML_NS root = self.container.parsed(name) ac = self.anchor_cache[name] = [] for item in set(root.xpath('//*[@id]')) | set(root.xpath('//h:a[@name]', namespaces={'h':XHTML_NS})): frag = item.get('id', None) or item.get('name') if not frag: continue text = lead_text(item, num_words=4) ac.append((text, frag)) ac.sort(key=lambda text_frag: numeric_sort_key(text_frag[0])) self.anchor_names.model().set_names(self.anchor_cache[name]) self.update_target() def update_target(self): rows = list(self.file_names.selectionModel().selectedRows()) if not rows: return name = self.file_names.model().data(rows[0], Qt.ItemDataRole.UserRole)[0] if name == self.source_name: href = '' else: href = self.container.name_to_href(name, self.source_name) frag = '' rows = list(self.anchor_names.selectionModel().selectedRows()) if rows: anchor = self.anchor_names.model().data(rows[0], Qt.ItemDataRole.UserRole)[1] if anchor: frag = '#' + anchor href += frag self.target.setText(href or '#') @property def href(self): return str(self.target.text()).strip() @property def text(self): return str(self.text_edit.text()).strip() @property def template(self): return self.template_edit.text().strip() or None @property def rendered_template(self): ans = self.template if ans: target = self.href frag = target.partition('#')[-1] if target.startswith('#'): target = '' else: target = target.split('#', 1)[0] target = self.container.href_to_name(target) ans = ans.replace('_SOURCE_FILENAME_', self.source_name or '') ans = ans.replace('_DEST_FILENAME_', target or '') ans = ans.replace('_ANCHOR_', frag or '') return ans @classmethod def test(cls): import sys from calibre.ebooks.oeb.polish.container import get_container c = get_container(sys.argv[-1], tweak_mode=True) d = cls(c, next(c.spine_names)[0]) if d.exec() == QDialog.DialogCode.Accepted: print(d.href, d.text) # }}} # Insert Semantics {{{ class InsertSemantics(Dialog): def __init__(self, container, parent=None): self.container = container self.create_known_type_map() self.anchor_cache = {} self.original_guide_map = {item['type']: item for item in get_guide_landmarks(container)} self.original_nav_map = {item['type']: item for item in get_nav_landmarks(container)} self.changes = {} Dialog.__init__(self, _('Set semantics'), 'insert-semantics', parent=parent) def sizeHint(self): return QSize(800, 600) def create_known_type_map(self): _ = lambda x: x self.epubtype_guide_map = {v: k for k, v in guide_epubtype_map.items()} self.known_type_map = { 'titlepage': _('Title page'), 'toc': _('Table of Contents'), 'index': _('Index'), 'glossary': _('Glossary'), 'acknowledgments': _('Acknowledgements'), 'bibliography': _('Bibliography'), 'colophon': _('Colophon'), 'cover': _('Cover'), 'copyright-page': _('Copyright page'), 'dedication': _('Dedication'), 'epigraph': _('Epigraph'), 'foreword': _('Foreword'), 'loi': _('List of illustrations'), 'lot': _('List of tables'), 'notes': _('Notes'), 'preface': _('Preface'), 'bodymatter': _('Text'), } _ = __builtins__['_'] type_map_help = { 'titlepage': _('Page with title, author, publisher, etc.'), 'cover': _('The book cover, typically a single HTML file with a cover image inside'), 'index': _('Back-of-book style index'), 'bodymatter': _('First "real" page of content'), } t = _ all_types = [(k, ((f'{t(v)} ({type_map_help[k]})') if k in type_map_help else t(v))) for k, v in iteritems(self.known_type_map)] all_types.sort(key=lambda x: sort_key(x[1])) self.all_types = OrderedDict(all_types) def setup_ui(self): self.l = l = QVBoxLayout(self) self.setLayout(l) self.tl = tl = QFormLayout() tl.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.semantic_type = QComboBox(self) for key, val in iteritems(self.all_types): self.semantic_type.addItem(val, key) tl.addRow(_('Type of &semantics:'), self.semantic_type) self.target = t = QLineEdit(self) t.setClearButtonEnabled(True) t.setPlaceholderText(_('The destination (href) for the link')) tl.addRow(_('&Target:'), t) l.addLayout(tl) self.hline = hl = QFrame(self) hl.setFrameStyle(QFrame.Shape.HLine) l.addWidget(hl) self.h = h = QHBoxLayout() l.addLayout(h) names = [n for n, linear in self.container.spine_names] fn, f = create_filterable_names_list(names, filter_text=_('Filter files'), parent=self) self.file_names, self.file_names_filter = fn, f fn.selectionModel().selectionChanged.connect(self.selected_file_changed) self.fnl = fnl = QVBoxLayout() self.la1 = la = QLabel(_('Choose a &file:')) la.setBuddy(fn) fnl.addWidget(la), fnl.addWidget(f), fnl.addWidget(fn) h.addLayout(fnl), h.setStretch(0, 2) fn, f = create_filterable_names_list([], filter_text=_('Filter locations'), parent=self) self.anchor_names, self.anchor_names_filter = fn, f fn.selectionModel().selectionChanged.connect(self.update_target) fn.doubleClicked.connect(self.accept, type=Qt.ConnectionType.QueuedConnection) self.anl = fnl = QVBoxLayout() self.la2 = la = QLabel(_('Choose a &location (anchor) in the file:')) la.setBuddy(fn) fnl.addWidget(la), fnl.addWidget(f), fnl.addWidget(fn) h.addLayout(fnl), h.setStretch(1, 1) self.bb.addButton(QDialogButtonBox.StandardButton.Help) self.bb.helpRequested.connect(self.help_requested) l.addWidget(self.bb) self.semantic_type_changed() self.semantic_type.currentIndexChanged.connect(self.semantic_type_changed) self.target.textChanged.connect(self.target_text_changed) def help_requested(self): d = info_dialog(self, _('About semantics'), _( 'Semantics refer to additional information about specific locations in the book.' ' For example, you can specify that a particular location is the dedication or the preface' ' or the Table of Contents and so on.\n\nFirst choose the type of semantic information, then' ' choose a file and optionally a location within the file to point to.\n\nThe' ' semantic information will be written in the <guide> section of the OPF file.')) d.resize(d.sizeHint()) d.exec() def dest_for_type(self, item_type): if item_type in self.changes: return self.changes[item_type] if item_type in self.original_nav_map: item = self.original_nav_map[item_type] return item['dest'], item['frag'] item_type = self.epubtype_guide_map.get(item_type, item_type) if item_type in self.original_guide_map: item = self.original_guide_map[item_type] return item['dest'], item['frag'] return None, None def semantic_type_changed(self): item_type = str(self.semantic_type.itemData(self.semantic_type.currentIndex()) or '') name, frag = self.dest_for_type(item_type) self.show_type(name, frag) def show_type(self, name, frag): self.file_names_filter.clear(), self.anchor_names_filter.clear() self.file_names.clearSelection(), self.anchor_names.clearSelection() if name is not None: row = self.file_names.model().find_name(name) if row is not None: sm = self.file_names.selectionModel() sm.select(self.file_names.model().index(row), QItemSelectionModel.SelectionFlag.ClearAndSelect) if frag: row = self.anchor_names.model().find_name(frag) if row is not None: sm = self.anchor_names.selectionModel() sm.select(self.anchor_names.model().index(row), QItemSelectionModel.SelectionFlag.ClearAndSelect) self.target.blockSignals(True) if name is not None: self.target.setText(name + (('#' + frag) if frag else '')) else: self.target.setText('') self.target.blockSignals(False) def target_text_changed(self): name, frag = str(self.target.text()).partition('#')[::2] item_type = str(self.semantic_type.itemData(self.semantic_type.currentIndex()) or '') if item_type: self.changes[item_type] = (name, frag or None) def selected_file_changed(self, *args): rows = list(self.file_names.selectionModel().selectedRows()) if not rows: self.anchor_names.model().set_names([]) else: name, positions = self.file_names.model().data(rows[0], Qt.ItemDataRole.UserRole) self.populate_anchors(name) def populate_anchors(self, name): if name not in self.anchor_cache: from calibre.ebooks.oeb.base import XHTML_NS root = self.container.parsed(name) self.anchor_cache[name] = sorted( (set(root.xpath('//*/@id')) | set(root.xpath('//h:a/@name', namespaces={'h':XHTML_NS}))) - {''}, key=primary_sort_key) self.anchor_names.model().set_names(self.anchor_cache[name]) self.update_target() def update_target(self): rows = list(self.file_names.selectionModel().selectedRows()) if not rows: return name = self.file_names.model().data(rows[0], Qt.ItemDataRole.UserRole)[0] href = name frag = '' rows = list(self.anchor_names.selectionModel().selectedRows()) if rows: anchor = self.anchor_names.model().data(rows[0], Qt.ItemDataRole.UserRole)[0] if anchor: frag = '#' + anchor href += frag self.target.setText(href or '#') def apply_changes(self, container): from calibre.ebooks.oeb.polish.opf import get_book_language, set_guide_item from calibre.translations.dynamic import translate lang = get_book_language(container) def title_for_type(item_type): title = self.known_type_map.get(item_type, item_type) if lang: title = translate(lang, title) return title for item_type, (name, frag) in self.changes.items(): set_guide_item(container, self.epubtype_guide_map[item_type], title_for_type(item_type), name, frag=frag) if container.opf_version_parsed.major > 2: final = self.original_nav_map.copy() for item_type, (name, frag) in self.changes.items(): final[item_type] = {'dest': name, 'frag': frag or '', 'title': title_for_type(item_type), 'type': item_type} tocname, root = ensure_container_has_nav(container, lang=lang) set_landmarks(container, root, tocname, final.values()) container.dirty(tocname) @classmethod def test(cls): import sys from calibre.ebooks.oeb.polish.container import get_container c = get_container(sys.argv[-1], tweak_mode=True) d = cls(c) if d.exec() == QDialog.DialogCode.Accepted: import pprint pprint.pprint(d.changed_type_map) d.apply_changes(d.container) # }}} class FilterCSS(Dialog): # {{{ def __init__(self, current_name=None, parent=None): self.current_name = current_name Dialog.__init__(self, _('Filter style information'), 'filter-css', parent=parent) def setup_ui(self): from calibre.gui2.convert.look_and_feel_ui import Ui_Form f, w = Ui_Form(), QWidget() f.setupUi(w) self.l = l = QFormLayout(self) self.setLayout(l) l.addRow(QLabel(_('Select what style information you want completely removed:'))) self.h = h = QHBoxLayout() for name, text in ( ('fonts', _('&Fonts')), ('margins', _('&Margins')), ('padding', _('&Padding')), ('floats', _('Flo&ats')), ('colors', _('&Colors')), ): c = QCheckBox(text) setattr(self, 'opt_' + name, c) h.addWidget(c) c.setToolTip(getattr(f, 'filter_css_' + name).toolTip()) l.addRow(h) self.others = o = QLineEdit(self) l.addRow(_('&Other CSS properties:'), o) o.setToolTip(f.filter_css_others.toolTip()) if self.current_name is not None: self.filter_current = c = QCheckBox(_('Only filter CSS in the current file (%s)') % self.current_name) l.addRow(c) l.addRow(self.bb) @property def filter_names(self): if self.current_name is not None and self.filter_current.isChecked(): return (self.current_name,) return () @property def filtered_properties(self): ans = set() a = ans.add if self.opt_fonts.isChecked(): a('font-family') if self.opt_margins.isChecked(): a('margin') if self.opt_padding.isChecked(): a('padding') if self.opt_floats.isChecked(): a('float'), a('clear') if self.opt_colors.isChecked(): a('color'), a('background-color') for x in str(self.others.text()).split(','): x = x.strip() if x: a(x) return ans @classmethod def test(cls): d = cls() if d.exec() == QDialog.DialogCode.Accepted: print(d.filtered_properties) # }}} # Add Cover {{{ class CoverView(QWidget): def __init__(self, parent=None): QWidget.__init__(self, parent) self.current_pixmap_size = QSize(0, 0) self.pixmap = QPixmap() self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def set_pixmap(self, data): self.pixmap.loadFromData(data) self.current_pixmap_size = self.pixmap.size() self.update() def paintEvent(self, event): if self.pixmap.isNull(): return canvas_size = self.rect() width = self.current_pixmap_size.width() extrax = canvas_size.width() - width if extrax < 0: extrax = 0 x = int(extrax/2.) height = self.current_pixmap_size.height() extray = canvas_size.height() - height if extray < 0: extray = 0 y = int(extray/2.) target = QRect(x, y, min(canvas_size.width(), width), min(canvas_size.height(), height)) p = QPainter(self) p.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform) p.drawPixmap(target, self.pixmap.scaled(target.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) p.end() def sizeHint(self): return QSize(300, 400) class AddCover(Dialog): import_requested = pyqtSignal(object, object) def __init__(self, container, parent=None): self.container = container Dialog.__init__(self, _('Add a cover'), 'add-cover-wizard', parent) @property def image_names(self): img_types = {guess_type('a.'+x) for x in ('png', 'jpeg', 'gif')} for name, mt in iteritems(self.container.mime_map): if mt.lower() in img_types: yield name def setup_ui(self): self.l = l = QVBoxLayout(self) self.setLayout(l) self.gb = gb = QGroupBox(_('&Images in book'), self) self.v = v = QVBoxLayout(gb) gb.setLayout(v), gb.setFlat(True) self.names, self.names_filter = create_filterable_names_list( sorted(self.image_names, key=sort_key), filter_text=_('Filter the list of images'), parent=self) self.names.doubleClicked.connect(self.double_clicked, type=Qt.ConnectionType.QueuedConnection) self.cover_view = CoverView(self) l.addWidget(self.names_filter) v.addWidget(self.names) self.splitter = s = QSplitter(self) l.addWidget(s) s.addWidget(gb) s.addWidget(self.cover_view) self.h = h = QHBoxLayout() self.preserve = p = QCheckBox(_('Preserve aspect ratio')) p.setToolTip(textwrap.fill(_('If enabled the cover image you select will be embedded' ' into the book in such a way that when viewed, its aspect' ' ratio (ratio of width to height) will be preserved.' ' This will mean blank spaces around the image if the screen' ' the book is being viewed on has an aspect ratio different' ' to the image.'))) p.setChecked(tprefs['add_cover_preserve_aspect_ratio']) p.setVisible(self.container.book_type != 'azw3') def on_state_change(s): tprefs.set('add_cover_preserve_aspect_ratio', s == Qt.CheckState.Checked) p.stateChanged.connect(on_state_change) self.info_label = il = QLabel('\xa0') h.addWidget(p), h.addStretch(1), h.addWidget(il) l.addLayout(h) l.addWidget(self.bb) b = self.bb.addButton(_('Import &image'), QDialogButtonBox.ButtonRole.ActionRole) b.clicked.connect(self.import_image) b.setIcon(QIcon(I('document_open.png'))) self.names.setFocus(Qt.FocusReason.OtherFocusReason) self.names.selectionModel().currentChanged.connect(self.current_image_changed) cname = get_raster_cover_name(self.container) if cname: row = self.names.model().find_name(cname) if row > -1: self.names.setCurrentIndex(self.names.model().index(row)) def double_clicked(self): self.accept() @property def file_name(self): return self.names.model().name_for_index(self.names.currentIndex()) def current_image_changed(self): self.info_label.setText('') name = self.file_name if name is not None: data = self.container.raw_data(name, decode=False) self.cover_view.set_pixmap(data) self.info_label.setText('{}x{}px | {}'.format( self.cover_view.pixmap.width(), self.cover_view.pixmap.height(), human_readable(len(data)))) def import_image(self): ans = choose_images(self, 'add-cover-choose-image', _('Choose a cover image'), formats=( 'jpg', 'jpeg', 'png', 'gif')) if ans: from calibre.gui2.tweak_book.file_list import NewFileDialog d = NewFileDialog(self) d.do_import_file(ans[0], hide_button=True) if d.exec() == QDialog.DialogCode.Accepted: self.import_requested.emit(d.file_name, d.file_data) self.container = current_container() self.names_filter.clear() self.names.model().set_names(sorted(self.image_names, key=sort_key)) i = self.names.model().find_name(d.file_name) self.names.setCurrentIndex(self.names.model().index(i)) self.current_image_changed() @classmethod def test(cls): import sys from calibre.ebooks.oeb.polish.container import get_container c = get_container(sys.argv[-1], tweak_mode=True) d = cls(c) if d.exec() == QDialog.DialogCode.Accepted: pass # }}} class PlainTextEdit(QPlainTextEdit): # {{{ ''' A class that overrides some methods from QPlainTextEdit to fix handling of the nbsp unicode character and AltGr input method on windows. ''' def __init__(self, parent=None): QPlainTextEdit.__init__(self, parent) self.syntax = None def toPlainText(self): return to_plain_text(self) def selected_text_from_cursor(self, cursor): return unicodedata.normalize('NFC', str(cursor.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0')) @property def selected_text(self): return self.selected_text_from_cursor(self.textCursor()) def createMimeDataFromSelection(self): ans = QMimeData() ans.setText(self.selected_text) return ans def show_tooltip(self, ev): pass def override_shortcut(self, ev): if iswindows and self.windows_ignore_altgr_shortcut(ev): ev.accept() return True def windows_ignore_altgr_shortcut(self, ev): from calibre_extensions import winutil s = winutil.get_async_key_state(winutil.VK_RMENU) # VK_RMENU == R_ALT return s & 0x8000 def event(self, ev): et = ev.type() if et == QEvent.Type.ToolTip: self.show_tooltip(ev) return True if et == QEvent.Type.ShortcutOverride: ret = self.override_shortcut(ev) if ret: return True return QPlainTextEdit.event(self, ev) # }}} if __name__ == '__main__': app = QApplication([]) AddCover.test()