%PDF- %PDF-
| Direktori : /usr/lib/calibre/calibre/gui2/viewer/ |
| Current File : //usr/lib/calibre/calibre/gui2/viewer/highlights.py |
#!/usr/bin/env python3
# License: GPL v3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
import json
import math
from collections import defaultdict
from functools import lru_cache
from itertools import chain
from qt.core import (
QAbstractItemView, QColor, QDialog, QFont, QHBoxLayout, QIcon, QImage,
QItemSelectionModel, QKeySequence, QLabel, QMenu, QPainter, QPainterPath,
QPalette, QPixmap, QPushButton, QRect, QSizePolicy, QStyle, Qt, QTextCursor,
QTextEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, pyqtSignal
)
from calibre.constants import (
builtin_colors_dark, builtin_colors_light, builtin_decorations
)
from calibre.ebooks.epub.cfi.parse import cfi_sort_key
from calibre.gui2 import error_dialog, is_dark_theme, safe_open_url
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.library.annotations import (
Details, Export as ExportBase, render_highlight_as_text, render_notes
)
from calibre.gui2.viewer import link_prefix_for_location_links
from calibre.gui2.viewer.config import vprefs
from calibre.gui2.viewer.search import SearchInput
from calibre.gui2.viewer.shortcuts import get_shortcut_for, index_to_key_sequence
from calibre.gui2.widgets2 import Dialog
from calibre_extensions.progress_indicator import set_no_activate_on_click
decoration_cache = {}
@lru_cache(maxsize=8)
def wavy_path(width, height, y_origin):
half_height = height / 2
path = QPainterPath()
pi2 = math.pi * 2
num = 100
num_waves = 4
wav_limit = num // num_waves
sin = math.sin
path.reserve(num)
for i in range(num):
x = width * i / num
rads = pi2 * (i % wav_limit) / wav_limit
factor = sin(rads)
y = y_origin + factor * half_height
path.lineTo(x, y) if i else path.moveTo(x, y)
return path
def decoration_for_style(palette, style, icon_size, device_pixel_ratio, is_dark):
style_key = (is_dark, icon_size, device_pixel_ratio, tuple((k, style[k]) for k in sorted(style)))
sentinel = object()
ans = decoration_cache.get(style_key, sentinel)
if ans is not sentinel:
return ans
ans = None
kind = style.get('kind')
if kind == 'color':
key = 'dark' if is_dark else 'light'
val = style.get(key)
if val is None:
which = style.get('which')
val = (builtin_colors_dark if is_dark else builtin_colors_light).get(which)
if val is None:
val = style.get('background-color')
if val is not None:
ans = QColor(val)
elif kind == 'decoration':
which = style.get('which')
if which is not None:
q = builtin_decorations.get(which)
if q is not None:
style = q
sz = int(math.ceil(icon_size * device_pixel_ratio))
canvas = QImage(sz, sz, QImage.Format.Format_ARGB32)
canvas.fill(Qt.GlobalColor.transparent)
canvas.setDevicePixelRatio(device_pixel_ratio)
p = QPainter(canvas)
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
p.setPen(palette.color(QPalette.ColorRole.WindowText))
irect = QRect(0, 0, icon_size, icon_size)
adjust = -2
text_rect = p.drawText(irect.adjusted(0, adjust, 0, adjust), Qt.AlignmentFlag.AlignHCenter| Qt.AlignmentFlag.AlignTop, 'a')
p.drawRect(irect)
fm = p.fontMetrics()
pen = p.pen()
if 'text-decoration-color' in style:
pen.setColor(QColor(style['text-decoration-color']))
lstyle = style.get('text-decoration-style') or 'solid'
q = {'dotted': Qt.PenStyle.DotLine, 'dashed': Qt.PenStyle.DashLine, }.get(lstyle)
if q is not None:
pen.setStyle(q)
lw = fm.lineWidth()
if lstyle == 'double':
lw * 2
pen.setWidth(fm.lineWidth())
q = style.get('text-decoration-line') or 'underline'
pos = text_rect.bottom()
height = irect.bottom() - pos
if q == 'overline':
pos = height
elif q == 'line-through':
pos = text_rect.center().y() - adjust - lw // 2
p.setPen(pen)
if lstyle == 'wavy':
p.drawPath(wavy_path(icon_size, height, pos))
else:
p.drawLine(0, pos, irect.right(), pos)
p.end()
ans = QPixmap.fromImage(canvas)
elif 'background-color' in style:
ans = QColor(style['background-color'])
decoration_cache[style_key] = ans
return ans
class Export(ExportBase):
prefs = vprefs
pref_name = 'highlight_export_format'
def file_type_data(self):
return _('calibre highlights'), 'calibre_highlights'
def initial_filename(self):
return _('highlights')
def exported_data(self):
fmt = self.export_format.currentData()
if fmt == 'calibre_highlights':
return json.dumps({
'version': 1,
'type': 'calibre_highlights',
'highlights': self.annotations,
}, ensure_ascii=False, sort_keys=True, indent=2)
lines = []
as_markdown = fmt == 'md'
link_prefix = link_prefix_for_location_links()
chapter_groups = {}
def_chap = (_('Unknown chapter'),)
for a in self.annotations:
toc_titles = a.get('toc_family_titles', def_chap)
chapter_groups.setdefault(toc_titles[0], []).append(a)
for chapter, group in chapter_groups.items():
if len(chapter_groups) > 1:
lines.append('### ' + chapter)
lines.append('')
for hl in group:
render_highlight_as_text(hl, lines, as_markdown=as_markdown, link_prefix=link_prefix)
return '\n'.join(lines).strip()
class Highlights(QTreeWidget):
jump_to_highlight = pyqtSignal(object)
current_highlight_changed = pyqtSignal(object)
delete_requested = pyqtSignal()
edit_requested = pyqtSignal()
edit_notes_requested = pyqtSignal()
def __init__(self, parent=None):
QTreeWidget.__init__(self, parent)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_context_menu)
self.default_decoration = QIcon(I('blank.png'))
self.setHeaderHidden(True)
self.num_of_items = 0
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
set_no_activate_on_click(self)
self.itemActivated.connect(self.item_activated)
self.currentItemChanged.connect(self.current_item_changed)
self.uuid_map = {}
self.section_font = QFont(self.font())
self.section_font.setItalic(True)
def show_context_menu(self, point):
index = self.indexAt(point)
h = index.data(Qt.ItemDataRole.UserRole)
self.context_menu = m = QMenu(self)
if h is not None:
m.addAction(QIcon(I('edit_input.png')), _('Modify this highlight'), self.edit_requested.emit)
m.addAction(QIcon(I('modified.png')), _('Edit notes for this highlight'), self.edit_notes_requested.emit)
m.addAction(QIcon(I('trash.png')), ngettext(
'Delete this highlight', 'Delete selected highlights', len(self.selectedItems())
), self.delete_requested.emit)
m.addSeparator()
m.addAction(QIcon.ic('plus.png'), _('Expand all'), self.expandAll)
m.addAction(QIcon.ic('minus.png'), _('Collapse all'), self.collapseAll)
self.context_menu.popup(self.mapToGlobal(point))
return True
def current_item_changed(self, current, previous):
self.current_highlight_changed.emit(current.data(0, Qt.ItemDataRole.UserRole) if current is not None else None)
def load(self, highlights, preserve_state=False):
s = self.style()
expanded_chapters = set()
if preserve_state:
root = self.invisibleRootItem()
for i in range(root.childCount()):
chapter = root.child(i)
if chapter.isExpanded():
expanded_chapters.add(chapter.data(0, Qt.ItemDataRole.DisplayRole))
icon_size = s.pixelMetric(QStyle.PixelMetric.PM_SmallIconSize, None, self)
dpr = self.devicePixelRatioF()
is_dark = is_dark_theme()
self.clear()
self.uuid_map = {}
highlights = (h for h in highlights if not h.get('removed') and h.get('highlighted_text'))
section_map = defaultdict(list)
section_tt_map = {}
for h in self.sorted_highlights(highlights):
tfam = h.get('toc_family_titles') or ()
if tfam:
tsec = tfam[0]
lsec = tfam[-1]
else:
tsec = h.get('top_level_section_title')
lsec = h.get('lowest_level_section_title')
sec = lsec or tsec or _('Unknown')
if len(tfam) > 1:
lines = []
for i, node in enumerate(tfam):
lines.append('\xa0\xa0' * i + '➤ ' + node)
tt = ngettext('Table of Contents section:', 'Table of Contents sections:', len(lines))
tt += '\n' + '\n'.join(lines)
section_tt_map[sec] = tt
section_map[sec].append(h)
for secnum, (sec, items) in enumerate(section_map.items()):
section = QTreeWidgetItem([sec], 1)
section.setFlags(Qt.ItemFlag.ItemIsEnabled)
section.setFont(0, self.section_font)
tt = section_tt_map.get(sec)
if tt:
section.setToolTip(0, tt)
self.addTopLevelItem(section)
section.setExpanded(not preserve_state or sec in expanded_chapters)
for itemnum, h in enumerate(items):
txt = h.get('highlighted_text')
txt = txt.replace('\n', ' ')
if h.get('notes'):
txt = '•' + txt
if len(txt) > 100:
txt = txt[:100] + '…'
item = QTreeWidgetItem(section, [txt], 2)
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren)
item.setData(0, Qt.ItemDataRole.UserRole, h)
try:
dec = decoration_for_style(self.palette(), h.get('style') or {}, icon_size, dpr, is_dark)
except Exception:
import traceback
traceback.print_exc()
dec = None
if dec is None:
dec = self.default_decoration
item.setData(0, Qt.ItemDataRole.DecorationRole, dec)
self.uuid_map[h['uuid']] = secnum, itemnum
self.num_of_items += 1
def sorted_highlights(self, highlights):
defval = 999999999999999, cfi_sort_key('/99999999')
def cfi_key(h):
cfi = h.get('start_cfi')
return (h.get('spine_index') or defval[0], cfi_sort_key(cfi)) if cfi else defval
return sorted(highlights, key=cfi_key)
def refresh(self, highlights):
h = self.current_highlight
self.load(highlights, preserve_state=True)
if h is not None:
idx = self.uuid_map.get(h['uuid'])
if idx is not None:
sec_idx, item_idx = idx
self.set_current_row(sec_idx, item_idx)
def iteritems(self):
root = self.invisibleRootItem()
for i in range(root.childCount()):
sec = root.child(i)
for k in range(sec.childCount()):
yield sec.child(k)
def count(self):
return self.num_of_items
def find_query(self, query):
pat = query.regex
items = tuple(self.iteritems())
count = len(items)
cr = -1
ch = self.current_highlight
if ch:
q = ch['uuid']
for i, item in enumerate(items):
h = item.data(0, Qt.ItemDataRole.UserRole)
if h['uuid'] == q:
cr = i
if query.backwards:
if cr < 0:
cr = count
indices = chain(range(cr - 1, -1, -1), range(count - 1, cr, -1))
else:
if cr < 0:
cr = -1
indices = chain(range(cr + 1, count), range(0, cr + 1))
for i in indices:
h = items[i].data(0, Qt.ItemDataRole.UserRole)
if pat.search(h['highlighted_text']) is not None or pat.search(h.get('notes') or '') is not None:
self.set_current_row(*self.uuid_map[h['uuid']])
return True
return False
def find_annot_id(self, annot_id):
q = self.uuid_map.get(annot_id)
if q is not None:
self.set_current_row(*q)
return True
return False
def set_current_row(self, sec_idx, item_idx):
sec = self.topLevelItem(sec_idx)
if sec is not None:
item = sec.child(item_idx)
if item is not None:
self.setCurrentItem(item, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect)
return True
return False
def item_activated(self, item):
h = item.data(0, Qt.ItemDataRole.UserRole)
if h is not None:
self.jump_to_highlight.emit(h)
@property
def current_highlight(self):
i = self.currentItem()
if i is not None:
return i.data(0, Qt.ItemDataRole.UserRole)
@property
def all_highlights(self):
for item in self.iteritems():
yield item.data(0, Qt.ItemDataRole.UserRole)
@property
def selected_highlights(self):
for item in self.selectedItems():
yield item.data(0, Qt.ItemDataRole.UserRole)
def keyPressEvent(self, ev):
if ev.matches(QKeySequence.StandardKey.Delete):
self.delete_requested.emit()
ev.accept()
return
if ev.key() == Qt.Key.Key_F2:
self.edit_requested.emit()
ev.accept()
return
return super().keyPressEvent(ev)
class NotesEditDialog(Dialog):
def __init__(self, notes, parent=None):
self.initial_notes = notes
Dialog.__init__(self, name='edit-notes-highlight', title=_('Edit notes'), parent=parent)
def setup_ui(self):
l = QVBoxLayout(self)
self.qte = qte = QTextEdit(self)
qte.setMinimumHeight(400)
qte.setMinimumWidth(600)
if self.initial_notes:
qte.setPlainText(self.initial_notes)
qte.moveCursor(QTextCursor.MoveOperation.End)
l.addWidget(qte)
l.addWidget(self.bb)
@property
def notes(self):
return self.qte.toPlainText().rstrip()
class NotesDisplay(Details):
notes_edited = pyqtSignal(object)
def __init__(self, parent=None):
Details.__init__(self, parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
self.anchorClicked.connect(self.anchor_clicked)
self.current_notes = ''
def show_notes(self, text=''):
text = (text or '').strip()
self.setVisible(bool(text))
self.current_notes = text
html = '\n'.join(render_notes(text))
self.setHtml('<div><a href="edit://moo">{}</a></div>{}'.format(_('Edit notes'), html))
self.document().setDefaultStyleSheet('a[href] { text-decoration: none }')
h = self.document().size().height() + 2
self.setMaximumHeight(int(h))
def anchor_clicked(self, qurl):
if qurl.scheme() == 'edit':
self.edit_notes()
else:
safe_open_url(qurl)
def edit_notes(self):
current_text = self.current_notes
d = NotesEditDialog(current_text, self)
if d.exec() == QDialog.DialogCode.Accepted and d.notes != current_text:
self.notes_edited.emit(d.notes)
class HighlightsPanel(QWidget):
jump_to_cfi = pyqtSignal(object)
request_highlight_action = pyqtSignal(object, object)
web_action = pyqtSignal(object, object)
toggle_requested = pyqtSignal()
notes_edited_signal = pyqtSignal(object, object)
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.l = l = QVBoxLayout(self)
l.setContentsMargins(0, 0, 0, 0)
self.search_input = si = SearchInput(self, 'highlights-search')
si.do_search.connect(self.search_requested)
l.addWidget(si)
la = QLabel(_('Double click to jump to an entry'))
la.setWordWrap(True)
l.addWidget(la)
self.highlights = h = Highlights(self)
l.addWidget(h)
h.jump_to_highlight.connect(self.jump_to_highlight)
h.delete_requested.connect(self.remove_highlight)
h.edit_requested.connect(self.edit_highlight)
h.edit_notes_requested.connect(self.edit_notes)
h.current_highlight_changed.connect(self.current_highlight_changed)
self.load = h.load
self.refresh = h.refresh
self.h = h = QHBoxLayout()
def button(icon, text, tt, target):
b = QPushButton(QIcon(I(icon)), text, self)
b.setToolTip(tt)
b.setFocusPolicy(Qt.FocusPolicy.NoFocus)
b.clicked.connect(target)
return b
self.edit_button = button('edit_input.png', _('Modify'), _('Modify the selected highlight'), self.edit_highlight)
self.remove_button = button('trash.png', _('Delete'), _('Delete the selected highlights'), self.remove_highlight)
self.export_button = button('save.png', _('Export'), _('Export all highlights'), self.export)
h.addWidget(self.edit_button), h.addWidget(self.remove_button), h.addWidget(self.export_button)
self.notes_display = nd = NotesDisplay(self)
nd.notes_edited.connect(self.notes_edited)
l.addWidget(nd)
nd.setVisible(False)
l.addLayout(h)
def notes_edited(self, text):
h = self.highlights.current_highlight
if h is not None:
h['notes'] = text
self.web_action.emit('set-notes-in-highlight', h)
self.notes_edited_signal.emit(h['uuid'], text)
def set_tooltips(self, rmap):
a = rmap.get('create_annotation')
if a:
def as_text(idx):
return index_to_key_sequence(idx).toString(QKeySequence.SequenceFormat.NativeText)
tt = self.add_button.toolTip().partition('[')[0].strip()
keys = sorted(filter(None, map(as_text, a)))
if keys:
self.add_button.setToolTip('{} [{}]'.format(tt, ', '.join(keys)))
def search_requested(self, query):
if not self.highlights.find_query(query):
error_dialog(self, _('No matches'), _(
'No highlights match the search: {}').format(query.text), show=True)
def focus(self):
self.highlights.setFocus(Qt.FocusReason.OtherFocusReason)
def jump_to_highlight(self, highlight):
self.request_highlight_action.emit(highlight['uuid'], 'goto')
def current_highlight_changed(self, highlight):
nd = self.notes_display
if highlight is None or not highlight.get('notes'):
nd.show_notes()
else:
nd.show_notes(highlight['notes'])
def no_selected_highlight(self):
error_dialog(self, _('No selected highlight'), _(
'No highlight is currently selected'), show=True)
def edit_highlight(self):
h = self.highlights.current_highlight
if h is None:
return self.no_selected_highlight()
self.request_highlight_action.emit(h['uuid'], 'edit')
def edit_notes(self):
self.notes_display.edit_notes()
def remove_highlight(self):
highlights = tuple(self.highlights.selected_highlights)
if not highlights:
return self.no_selected_highlight()
if confirm(
ngettext(
'Are you sure you want to delete this highlight permanently?',
'Are you sure you want to delete all {} highlights permanently?',
len(highlights)).format(len(highlights)),
'delete-highlight-from-viewer', parent=self, config_set=vprefs
):
for h in highlights:
self.request_highlight_action.emit(h['uuid'], 'delete')
def export(self):
hl = list(self.highlights.all_highlights)
if not hl:
return error_dialog(self, _('No highlights'), _('This book has no highlights to export'), show=True)
Export(hl, self).exec()
def selected_text_changed(self, text, annot_id):
if annot_id:
self.highlights.find_annot_id(annot_id)
def keyPressEvent(self, ev):
sc = get_shortcut_for(self, ev)
if sc == 'toggle_highlights' or ev.key() == Qt.Key.Key_Escape:
self.toggle_requested.emit()
return super().keyPressEvent(ev)