%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/ |
Current File : //lib/calibre/calibre/gui2/comments_editor.py |
#!/usr/bin/env python3 # License: GPLv3 Copyright: 2010, Kovid Goyal <kovid at kovidgoyal.net> import os import re import weakref from collections import defaultdict from contextlib import contextmanager from html5_parser import parse from lxml import html from qt.core import ( QAction, QApplication, QBrush, QByteArray, QCheckBox, QColor, QColorDialog, QDialog, QDialogButtonBox, QFont, QFontInfo, QFontMetrics, QFormLayout, QIcon, QKeySequence, QLabel, QLineEdit, QMenu, QPalette, QPlainTextEdit, QPushButton, QSize, QSyntaxHighlighter, Qt, QTabWidget, QTextBlockFormat, QTextCharFormat, QTextCursor, QTextEdit, QTextFormat, QTextListFormat, QTimer, QToolButton, QUrl, QVBoxLayout, QWidget, pyqtSignal, pyqtSlot ) from calibre import xml_replace_entities from calibre.ebooks.chardet import xml_to_unicode from calibre.gui2 import ( NO_URL_FORMATTING, choose_files, error_dialog, gprefs, is_dark_theme ) from calibre.gui2.book_details import css from calibre.gui2.flow_toolbar import create_flow_toolbar from calibre.gui2.widgets import LineEditECM from calibre.gui2.widgets2 import to_plain_text from calibre.utils.cleantext import clean_xml_chars from calibre.utils.config import tweaks from calibre.utils.imghdr import what from polyglot.builtins import iteritems, itervalues # Cleanup Qt markup {{{ def parse_style(style): props = filter(None, (x.strip() for x in style.split(';'))) ans = {} for prop in props: try: k, v = prop.split(':', 1) except Exception: continue ans[k.strip().lower()] = v.strip() return ans liftable_props = ('font-style', 'font-weight', 'font-family', 'font-size') def lift_styles(tag, style_map): common_props = None has_text = bool(tag.text) child_styles = [] for child in tag.iterchildren('*'): if child.tail: has_text = True style = style_map[child] child_styles.append(style) if common_props is None: common_props = style.copy() else: for k, v in tuple(iteritems(common_props)): if style.get(k) != v: del common_props[k] if not has_text and common_props: lifted_props = [] tag_style = style_map[tag] for k in liftable_props: if k in common_props: lifted_props.append(k) tag_style[k] = common_props[k] if lifted_props: for style in child_styles: for k in lifted_props: del style[k] def filter_qt_styles(style): for k in tuple(style): # -qt-paragraph-type is a hack used by Qt for empty paragraphs if k.startswith('-qt-'): del style[k] def remove_margins(tag, style): ml, mr, mt, mb = (style.pop('margin-' + k, None) for k in 'left right top bottom'.split()) is_blockquote = ml == mr and ml and ml != '0px' and (ml != mt or ml != mb) if is_blockquote: tag.tag = 'blockquote' def remove_zero_indents(style): ti = style.get('text-indent') if ti == '0px': del style['text-indent'] def remove_heading_font_styles(tag, style): lvl = int(tag.tag[1:]) expected_size = (None, 'xx-large', 'x-large', 'large', None, 'small', 'x-small')[lvl] if style.get('font-size', 1) == expected_size: del style['font-size'] if style.get('font-weight') == '600': del style['font-weight'] def use_implicit_styling_for_span(span, style): is_italic = style.get('font-style') == 'italic' is_bold = style.get('font-weight') == '600' if is_italic and not is_bold: del style['font-style'] span.tag = 'em' elif is_bold and not is_italic: del style['font-weight'] span.tag = 'strong' if span.tag == 'span' and style.get('text-decoration') == 'underline': span.tag = 'u' del style['text-decoration'] if span.tag == 'span' and style.get('text-decoration') == 'line-through': span.tag = 's' del style['text-decoration'] if span.tag == 'span' and style.get('vertical-align') in ('sub', 'super'): span.tag = 'sub' if style.pop('vertical-align') == 'sub' else 'sup' def use_implicit_styling_for_a(a, style_map): for span in a.iterchildren('span'): style = style_map[span] if style.get('text-decoration') == 'underline': del style['text-decoration'] if style.get('color') == '#0000ff': del style['color'] break def merge_contiguous_links(root): all_hrefs = set(root.xpath('//a/@href')) for href in all_hrefs: tags = root.xpath(f'//a[@href="{href}"]') processed = set() def insert_tag(parent, child): parent.tail = child.tail if child.text: children = parent.getchildren() if children: children[-1].tail = (children[-1].tail or '') + child.text else: parent.text = (parent.text or '') + child.text for gc in child.iterchildren('*'): parent.append(gc) for a in tags: if a in processed or a.tail: continue processed.add(a) n = a remove = [] while not n.tail and n.getnext() is not None and getattr(n.getnext(), 'tag', None) == 'a' and n.getnext().get('href') == href: n = n.getnext() processed.add(n) remove.append(n) for n in remove: insert_tag(a, n) n.getparent().remove(n) def convert_anchors_to_ids(root): anchors = root.xpath('//a[@name]') for a in anchors: p = a.getparent() if len(a.attrib) == 1 and not p.text and a is p[0] and not a.text and not p.get('id') and a.get('name') and len(a) == 0: p.text = a.tail p.set('id', a.get('name')) p.remove(a) def cleanup_qt_markup(root): from calibre.ebooks.docx.cleanup import lift style_map = defaultdict(dict) for tag in root.xpath('//*[@style]'): style_map[tag] = parse_style(tag.get('style')) block_tags = root.xpath('//body/*') for tag in block_tags: lift_styles(tag, style_map) tag_style = style_map[tag] remove_margins(tag, tag_style) remove_zero_indents(tag_style) if tag.tag.startswith('h') and tag.tag[1:] in '123456': remove_heading_font_styles(tag, tag_style) for child in tag.iterdescendants('a'): use_implicit_styling_for_a(child, style_map) for child in tag.iterdescendants('span'): use_implicit_styling_for_span(child, style_map[child]) if tag.tag == 'p' and style_map[tag].get('-qt-paragraph-type') == 'empty': del tag[:] tag.text = '\xa0' if tag.tag in ('ol', 'ul'): for li in tag.iterdescendants('li'): ts = style_map.get(li) if ts: remove_margins(li, ts) remove_zero_indents(ts) for style in itervalues(style_map): filter_qt_styles(style) for tag, style in iteritems(style_map): if style: tag.set('style', '; '.join(f'{k}: {v}' for k, v in iteritems(style))) else: tag.attrib.pop('style', None) for span in root.xpath('//span[not(@style)]'): lift(span) merge_contiguous_links(root) convert_anchors_to_ids(root) # }}} class EditorWidget(QTextEdit, LineEditECM): # {{{ data_changed = pyqtSignal() @property def readonly(self): return self.isReadOnly() @readonly.setter def readonly(self, val): self.setReadOnly(bool(val)) @contextmanager def editing_cursor(self, set_cursor=True): c = self.textCursor() c.beginEditBlock() yield c c.endEditBlock() if set_cursor: self.setTextCursor(c) self.focus_self() def __init__(self, parent=None): QTextEdit.__init__(self, parent) self.setTabChangesFocus(True) self.document().setDefaultStyleSheet(css() + '\n\nli { margin-top: 0.5ex; margin-bottom: 0.5ex; }') font = self.font() f = QFontInfo(font) delta = tweaks['change_book_details_font_size_by'] + 1 if delta: font.setPixelSize(f.pixelSize() + delta) self.setFont(font) f = QFontMetrics(self.font()) self.em_size = f.horizontalAdvance('m') self.base_url = None self._parent = weakref.ref(parent) self.comments_pat = re.compile(r'<!--.*?-->', re.DOTALL) def r(name, icon, text, checkable=False, shortcut=None): ac = QAction(QIcon(I(icon + '.png')), text, self) ac.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) if checkable: ac.setCheckable(checkable) setattr(self, 'action_'+name, ac) ac.triggered.connect(getattr(self, 'do_' + name)) if shortcut is not None: sc = shortcut if isinstance(shortcut, QKeySequence) else QKeySequence(shortcut) ac.setShortcut(sc) ac.setToolTip(text + f' [{sc.toString(QKeySequence.SequenceFormat.NativeText)}]') self.addAction(ac) r('bold', 'format-text-bold', _('Bold'), True, QKeySequence.StandardKey.Bold) r('italic', 'format-text-italic', _('Italic'), True, QKeySequence.StandardKey.Italic) r('underline', 'format-text-underline', _('Underline'), True, QKeySequence.StandardKey.Underline) r('strikethrough', 'format-text-strikethrough', _('Strikethrough'), True) r('superscript', 'format-text-superscript', _('Superscript'), True) r('subscript', 'format-text-subscript', _('Subscript'), True) r('ordered_list', 'format-list-ordered', _('Ordered list'), True) r('unordered_list', 'format-list-unordered', _('Unordered list'), True) r('align_left', 'format-justify-left', _('Align left'), True) r('align_center', 'format-justify-center', _('Align center'), True) r('align_right', 'format-justify-right', _('Align right'), True) r('align_justified', 'format-justify-fill', _('Align justified'), True) r('undo', 'edit-undo', _('Undo'), shortcut=QKeySequence.StandardKey.Undo) r('redo', 'edit-redo', _('Redo'), shortcut=QKeySequence.StandardKey.Redo) r('remove_format', 'edit-clear', _('Remove formatting')) r('copy', 'edit-copy', _('Copy'), shortcut=QKeySequence.StandardKey.Copy) r('paste', 'edit-paste', _('Paste'), shortcut=QKeySequence.StandardKey.Paste) r('paste_and_match_style', 'edit-paste', _('Paste and match style')) r('cut', 'edit-cut', _('Cut'), shortcut=QKeySequence.StandardKey.Cut) r('indent', 'format-indent-more', _('Increase indentation')) r('outdent', 'format-indent-less', _('Decrease indentation')) r('select_all', 'edit-select-all', _('Select all'), shortcut=QKeySequence.StandardKey.SelectAll) r('color', 'format-text-color', _('Foreground color')) r('background', 'format-fill-color', _('Background color')) r('insert_link', 'insert-link', _('Insert link or image'), shortcut=QKeySequence('Ctrl+l', QKeySequence.SequenceFormat.PortableText)) r('insert_hr', 'format-text-hr', _('Insert separator'),) r('clear', 'trash', _('Clear')) self.action_block_style = QAction(QIcon(I('format-text-heading.png')), _('Style text block'), self) self.action_block_style.setToolTip( _('Style the selected text block')) self.block_style_menu = QMenu(self) self.action_block_style.setMenu(self.block_style_menu) self.block_style_actions = [] h = _('Heading {0}') for text, name in ( (_('Normal'), 'p'), (h.format(1), 'h1'), (h.format(2), 'h2'), (h.format(3), 'h3'), (h.format(4), 'h4'), (h.format(5), 'h5'), (h.format(6), 'h6'), (_('Blockquote'), 'blockquote'), ): ac = QAction(text, self) self.block_style_menu.addAction(ac) ac.block_name = name ac.setCheckable(True) self.block_style_actions.append(ac) ac.triggered.connect(self.do_format_block) self.setHtml('') self.copyAvailable.connect(self.update_clipboard_actions) self.update_clipboard_actions(False) self.selectionChanged.connect(self.update_selection_based_actions) self.update_selection_based_actions() connect_lambda(self.undoAvailable, self, lambda self, yes: self.action_undo.setEnabled(yes)) connect_lambda(self.redoAvailable, self, lambda self, yes: self.action_redo.setEnabled(yes)) self.action_undo.setEnabled(False), self.action_redo.setEnabled(False) self.textChanged.connect(self.update_cursor_position_actions) self.cursorPositionChanged.connect(self.update_cursor_position_actions) self.textChanged.connect(self.data_changed) self.update_cursor_position_actions() def update_clipboard_actions(self, copy_available): self.action_copy.setEnabled(copy_available) self.action_cut.setEnabled(copy_available) def update_selection_based_actions(self): pass def update_cursor_position_actions(self): c = self.textCursor() tcf = c.charFormat() ls = c.currentList() self.action_ordered_list.setChecked(ls is not None and ls.format().style() == QTextListFormat.Style.ListDecimal) self.action_unordered_list.setChecked(ls is not None and ls.format().style() == QTextListFormat.Style.ListDisc) vert = tcf.verticalAlignment() self.action_superscript.setChecked(vert == QTextCharFormat.VerticalAlignment.AlignSuperScript) self.action_subscript.setChecked(vert == QTextCharFormat.VerticalAlignment.AlignSubScript) self.action_bold.setChecked(tcf.fontWeight() == QFont.Weight.Bold) self.action_italic.setChecked(tcf.fontItalic()) self.action_underline.setChecked(tcf.fontUnderline()) self.action_strikethrough.setChecked(tcf.fontStrikeOut()) bf = c.blockFormat() a = bf.alignment() self.action_align_left.setChecked(a == Qt.AlignmentFlag.AlignLeft) self.action_align_right.setChecked(a == Qt.AlignmentFlag.AlignRight) self.action_align_center.setChecked(a == Qt.AlignmentFlag.AlignHCenter) self.action_align_justified.setChecked(a == Qt.AlignmentFlag.AlignJustify) lvl = bf.headingLevel() name = 'p' if lvl == 0: if bf.leftMargin() == bf.rightMargin() and bf.leftMargin() > 0: name = 'blockquote' else: name = f'h{lvl}' for ac in self.block_style_actions: ac.setChecked(ac.block_name == name) def set_readonly(self, what): self.readonly = what def focus_self(self): self.setFocus(Qt.FocusReason.TabFocusReason) def do_clear(self, *args): c = self.textCursor() c.beginEditBlock() c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.MoveAnchor) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) c.removeSelectedText() c.endEditBlock() self.focus_self() clear_text = do_clear def do_bold(self): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setFontWeight( QFont.Weight.Bold if c.charFormat().fontWeight() != QFont.Weight.Bold else QFont.Weight.Normal) c.mergeCharFormat(fmt) self.update_cursor_position_actions() def do_italic(self): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setFontItalic(not c.charFormat().fontItalic()) c.mergeCharFormat(fmt) self.update_cursor_position_actions() def do_underline(self): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setFontUnderline(not c.charFormat().fontUnderline()) c.mergeCharFormat(fmt) self.update_cursor_position_actions() def do_strikethrough(self): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setFontStrikeOut(not c.charFormat().fontStrikeOut()) c.mergeCharFormat(fmt) self.update_cursor_position_actions() def do_vertical_align(self, which): with self.editing_cursor() as c: fmt = QTextCharFormat() fmt.setVerticalAlignment(which) c.mergeCharFormat(fmt) self.update_cursor_position_actions() def do_superscript(self): self.do_vertical_align(QTextCharFormat.VerticalAlignment.AlignSuperScript) def do_subscript(self): self.do_vertical_align(QTextCharFormat.VerticalAlignment.AlignSubScript) def do_list(self, fmt): with self.editing_cursor() as c: ls = c.currentList() if ls is not None: lf = ls.format() if lf.style() == fmt: c.setBlockFormat(QTextBlockFormat()) else: lf.setStyle(fmt) ls.setFormat(lf) else: ls = c.createList(fmt) self.update_cursor_position_actions() def do_ordered_list(self): self.do_list(QTextListFormat.Style.ListDecimal) def do_unordered_list(self): self.do_list(QTextListFormat.Style.ListDisc) def do_alignment(self, which): with self.editing_cursor() as c: c = self.textCursor() fmt = QTextBlockFormat() fmt.setAlignment(which) c.mergeBlockFormat(fmt) self.update_cursor_position_actions() def do_align_left(self): self.do_alignment(Qt.AlignmentFlag.AlignLeft) def do_align_center(self): self.do_alignment(Qt.AlignmentFlag.AlignHCenter) def do_align_right(self): self.do_alignment(Qt.AlignmentFlag.AlignRight) def do_align_justified(self): self.do_alignment(Qt.AlignmentFlag.AlignJustify) def do_undo(self): self.undo() self.focus_self() def do_redo(self): self.redo() self.focus_self() def do_remove_format(self): with self.editing_cursor() as c: c.setBlockFormat(QTextBlockFormat()) c.setCharFormat(QTextCharFormat()) self.update_cursor_position_actions() def do_copy(self): self.copy() self.focus_self() def do_paste(self): self.paste() self.focus_self() def do_paste_and_match_style(self): text = QApplication.instance().clipboard().text() if text: self.setText(text) def do_cut(self): self.cut() self.focus_self() def indent_block(self, mult=1): with self.editing_cursor() as c: bf = c.blockFormat() bf.setTextIndent(bf.textIndent() + 2 * self.em_size * mult) c.setBlockFormat(bf) self.update_cursor_position_actions() def do_indent(self): self.indent_block() def do_outdent(self): self.indent_block(-1) def do_select_all(self): with self.editing_cursor() as c: c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.MoveAnchor) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) def level_for_block_type(self, name): if name == 'blockquote': return 0 return {q: i for i, q in enumerate('p h1 h2 h3 h4 h5 h6'.split())}[name] def do_format_block(self): name = self.sender().block_name with self.editing_cursor() as c: bf = QTextBlockFormat() cf = QTextCharFormat() bcf = c.blockCharFormat() lvl = self.level_for_block_type(name) wt = QFont.Weight.Bold if lvl else None adjust = (0, 3, 2, 1, 0, -1, -1)[lvl] pos = None if not c.hasSelection(): pos = c.position() c.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.MoveAnchor) c.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor) # margin values are taken from qtexthtmlparser.cpp hmargin = 0 if name == 'blockquote': hmargin = 40 tmargin = bmargin = 12 if name == 'h1': tmargin, bmargin = 18, 12 elif name == 'h2': tmargin, bmargin = 16, 12 elif name == 'h3': tmargin, bmargin = 14, 12 elif name == 'h4': tmargin, bmargin = 12, 12 elif name == 'h5': tmargin, bmargin = 12, 4 bf.setLeftMargin(hmargin), bf.setRightMargin(hmargin) bf.setTopMargin(tmargin), bf.setBottomMargin(bmargin) bf.setHeadingLevel(lvl) if adjust: bcf.setProperty(QTextFormat.Property.FontSizeAdjustment, adjust) cf.setProperty(QTextFormat.Property.FontSizeAdjustment, adjust) if wt: bcf.setProperty(QTextFormat.Property.FontWeight, wt) cf.setProperty(QTextFormat.Property.FontWeight, wt) c.setBlockCharFormat(bcf) c.mergeCharFormat(cf) c.mergeBlockFormat(bf) if pos is not None: c.setPosition(pos) self.update_cursor_position_actions() def do_color(self): col = QColorDialog.getColor(Qt.GlobalColor.black, self, _('Choose foreground color'), QColorDialog.ColorDialogOption.ShowAlphaChannel) if col.isValid(): fmt = QTextCharFormat() fmt.setForeground(QBrush(col)) with self.editing_cursor() as c: c.mergeCharFormat(fmt) def do_background(self): col = QColorDialog.getColor(Qt.GlobalColor.white, self, _('Choose background color'), QColorDialog.ColorDialogOption.ShowAlphaChannel) if col.isValid(): fmt = QTextCharFormat() fmt.setBackground(QBrush(col)) with self.editing_cursor() as c: c.mergeCharFormat(fmt) def do_insert_hr(self, *args): with self.editing_cursor() as c: c.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.MoveAnchor) c.insertHtml('<hr>') def do_insert_link(self, *args): link, name, is_image = self.ask_link() if not link: return url = self.parse_link(link) if url.isValid(): url = str(url.toString(NO_URL_FORMATTING)) self.focus_self() with self.editing_cursor() as c: if is_image: c.insertImage(url) else: oldfmt = QTextCharFormat(c.charFormat()) fmt = QTextCharFormat() fmt.setAnchor(True) fmt.setAnchorHref(url) fmt.setForeground(QBrush(self.palette().color(QPalette.ColorRole.Link))) if name or not c.hasSelection(): c.mergeCharFormat(fmt) c.insertText(name or url) else: pos, anchor = c.position(), c.anchor() start, end = min(pos, anchor), max(pos, anchor) for i in range(start, end): cur = self.textCursor() cur.setPosition(i), cur.setPosition(i + 1, QTextCursor.MoveMode.KeepAnchor) cur.mergeCharFormat(fmt) c.setPosition(c.position()) c.setCharFormat(oldfmt) else: error_dialog(self, _('Invalid URL'), _('The url %r is invalid') % link, show=True) def ask_link(self): class Ask(QDialog): def accept(self): if self.treat_as_image.isChecked(): url = self.url.text() if url.lower().split(':', 1)[0] in ('http', 'https'): error_dialog(self, _('Remote images not supported'), _( 'You must download the image to your computer, URLs pointing' ' to remote images are not supported.'), show=True) return QDialog.accept(self) d = Ask(self) d.setWindowTitle(_('Create link')) l = QFormLayout() l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) d.setLayout(l) d.url = QLineEdit(d) d.name = QLineEdit(d) d.treat_as_image = QCheckBox(d) d.setMinimumWidth(600) d.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel) d.br = b = QPushButton(_('&Browse')) b.setIcon(QIcon(I('document_open.png'))) def cf(): filetypes = [] if d.treat_as_image.isChecked(): filetypes = [(_('Images'), 'png jpeg jpg gif'.split())] files = choose_files(d, 'select link file', _('Choose file'), filetypes, select_only_single_file=True) if files: path = files[0] d.url.setText(path) if path and os.path.exists(path): with lopen(path, 'rb') as f: q = what(f) is_image = q in {'jpeg', 'png', 'gif'} d.treat_as_image.setChecked(is_image) b.clicked.connect(cf) d.la = la = QLabel(_( 'Enter a URL. If you check the "Treat the URL as an image" box ' 'then the URL will be added as an image reference instead of as ' 'a link. You can also choose to create a link to a file on ' 'your computer. ' 'Note that if you create a link to a file on your computer, it ' 'will stop working if the file is moved.')) la.setWordWrap(True) la.setStyleSheet('QLabel { margin-bottom: 1.5ex }') l.setWidget(0, QFormLayout.ItemRole.SpanningRole, la) l.addRow(_('Enter &URL:'), d.url) l.addRow(_('Treat the URL as an &image'), d.treat_as_image) l.addRow(_('Enter &name (optional):'), d.name) l.addRow(_('Choose a file on your computer:'), d.br) l.addRow(d.bb) d.bb.accepted.connect(d.accept) d.bb.rejected.connect(d.reject) d.resize(d.sizeHint()) link, name, is_image = None, None, False if d.exec() == QDialog.DialogCode.Accepted: link, name = str(d.url.text()).strip(), str(d.name.text()).strip() is_image = d.treat_as_image.isChecked() return link, name, is_image def parse_link(self, link): link = link.strip() if link and os.path.exists(link): return QUrl.fromLocalFile(link) has_schema = re.match(r'^[a-zA-Z]+:', link) if has_schema is not None: url = QUrl(link, QUrl.ParsingMode.TolerantMode) if url.isValid(): return url if os.path.exists(link): return QUrl.fromLocalFile(link) if has_schema is None: first, _, rest = link.partition('.') prefix = 'http' if first == 'ftp': prefix = 'ftp' url = QUrl(prefix +'://'+link, QUrl.ParsingMode.TolerantMode) if url.isValid(): return url return QUrl(link, QUrl.ParsingMode.TolerantMode) def sizeHint(self): return QSize(150, 150) @property def html(self): raw = original_html = self.toHtml() check = self.toPlainText().strip() raw = xml_to_unicode(raw, strip_encoding_pats=True, resolve_entities=True)[0] raw = self.comments_pat.sub('', raw) if not check and '<img' not in raw.lower(): return '' try: root = parse(raw, maybe_xhtml=False, sanitize_names=True) except Exception: root = parse(clean_xml_chars(raw), maybe_xhtml=False, sanitize_names=True) if root.xpath('//meta[@name="calibre-dont-sanitize"]'): # Bypass cleanup if special meta tag exists return original_html try: cleanup_qt_markup(root) except Exception: import traceback traceback.print_exc() elems = [] for body in root.xpath('//body'): if body.text: elems.append(body.text) elems += [html.tostring(x, encoding='unicode') for x in body if x.tag not in ('script', 'style')] if len(elems) > 1: ans = '<div>%s</div>'%(''.join(elems)) else: ans = ''.join(elems) if not ans.startswith('<'): ans = '<p>%s</p>'%ans return xml_replace_entities(ans) @html.setter def html(self, val): self.setHtml(val) def set_base_url(self, qurl): self.base_url = qurl @pyqtSlot(int, 'QUrl', result='QVariant') def loadResource(self, rtype, qurl): if self.base_url: if qurl.isRelative(): qurl = self.base_url.resolved(qurl) if qurl.isLocalFile(): path = qurl.toLocalFile() try: with lopen(path, 'rb') as f: data = f.read() except OSError: if path.rpartition('.')[-1].lower() in {'jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp'}: return QByteArray(bytearray.fromhex( '89504e470d0a1a0a0000000d49484452' '000000010000000108060000001f15c4' '890000000a49444154789c6300010000' '0500010d0a2db40000000049454e44ae' '426082')) else: return QByteArray(data) def set_html(self, val, allow_undo=True): if not allow_undo or self.readonly: self.html = val return with self.editing_cursor() as c: c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.MoveAnchor) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) c.removeSelectedText() c.insertHtml(val) def text(self): return self.textCursor().selectedText() def setText(self, text): with self.editing_cursor() as c: c.insertText(text) def contextMenuEvent(self, ev): menu = self.createStandardContextMenu() for action in menu.actions(): parts = action.text().split('\t') if len(parts) == 2 and QKeySequence(QKeySequence.StandardKey.Paste).toString(QKeySequence.SequenceFormat.NativeText) in parts[-1]: menu.insertAction(action, self.action_paste_and_match_style) break else: menu.addAction(self.action_paste_and_match_style) st = self.text() m = QMenu(_('Fonts')) m.addAction(self.action_bold), m.addAction(self.action_italic), m.addAction(self.action_underline) menu.addMenu(m) if st and st.strip(): self.create_change_case_menu(menu) parent = self._parent() if hasattr(parent, 'toolbars_visible'): vis = parent.toolbars_visible menu.addAction(_('%s toolbars') % (_('Hide') if vis else _('Show')), parent.toggle_toolbars) menu.addSeparator() am = QMenu(_('Advanced')) menu.addMenu(am) am.addAction(self.action_block_style) am.addAction(self.action_insert_link) am.addAction(self.action_background) am.addAction(self.action_color) menu.addAction(_('Smarten punctuation'), parent.smarten_punctuation) menu.exec(ev.globalPos()) # }}} # Highlighter {{{ State_Text = -1 State_DocType = 0 State_Comment = 1 State_TagStart = 2 State_TagName = 3 State_InsideTag = 4 State_AttributeName = 5 State_SingleQuote = 6 State_DoubleQuote = 7 State_AttributeValue = 8 class Highlighter(QSyntaxHighlighter): def __init__(self, doc): QSyntaxHighlighter.__init__(self, doc) self.colors = {} self.colors['doctype'] = QColor(192, 192, 192) self.colors['entity'] = QColor(128, 128, 128) self.colors['comment'] = QColor(35, 110, 37) if is_dark_theme(): from calibre.gui2.palette import dark_link_color self.colors['tag'] = QColor(186, 78, 188) self.colors['attrname'] = QColor(193, 119, 60) self.colors['attrval'] = dark_link_color else: self.colors['tag'] = QColor(136, 18, 128) self.colors['attrname'] = QColor(153, 69, 0) self.colors['attrval'] = QColor(36, 36, 170) def highlightBlock(self, text): state = self.previousBlockState() len_ = len(text) start = 0 pos = 0 while pos < len_: if state == State_Comment: start = pos while pos < len_: if text[pos:pos+3] == "-->": pos += 3 state = State_Text break else: pos += 1 self.setFormat(start, pos - start, self.colors['comment']) elif state == State_DocType: start = pos while pos < len_: ch = text[pos] pos += 1 if ch == '>': state = State_Text break self.setFormat(start, pos - start, self.colors['doctype']) # at '<' in e.g. "<span>foo</span>" elif state == State_TagStart: start = pos + 1 while pos < len_: ch = text[pos] pos += 1 if ch == '>': state = State_Text break if not ch.isspace(): pos -= 1 state = State_TagName break # at 'b' in e.g "<blockquote>foo</blockquote>" elif state == State_TagName: start = pos while pos < len_: ch = text[pos] pos += 1 if ch.isspace(): pos -= 1 state = State_InsideTag break if ch == '>': state = State_Text break self.setFormat(start, pos - start, self.colors['tag']) # anywhere after tag name and before tag closing ('>') elif state == State_InsideTag: start = pos while pos < len_: ch = text[pos] pos += 1 if ch == '/': continue if ch == '>': state = State_Text self.setFormat(pos-1, 1, self.colors['tag']) break if not ch.isspace(): pos -= 1 state = State_AttributeName break # at 's' in e.g. <img src=bla.png/> elif state == State_AttributeName: start = pos while pos < len_: ch = text[pos] pos += 1 if ch == '=': state = State_AttributeValue break if ch in ('>', '/'): state = State_InsideTag break self.setFormat(start, pos - start, self.colors['attrname']) # after '=' in e.g. <img src=bla.png/> elif state == State_AttributeValue: start = pos # find first non-space character while pos < len_: ch = text[pos] pos += 1 # handle opening single quote if ch == "'": state = State_SingleQuote self.setFormat(pos - 1, 1, self.colors['attrval']) break # handle opening double quote if ch == '"': state = State_DoubleQuote self.setFormat(pos - 1, 1, self.colors['attrval']) break if not ch.isspace(): break if state == State_AttributeValue: # attribute value without quote # just stop at non-space or tag delimiter start = pos while pos < len_: ch = text[pos] if ch.isspace(): break if ch in ('>', '/'): break pos += 1 state = State_InsideTag self.setFormat(start, pos - start, self.colors['attrval']) # after the opening single quote in an attribute value elif state == State_SingleQuote: start = pos while pos < len_: ch = text[pos] pos += 1 if ch == "'": break state = State_InsideTag self.setFormat(start, pos - start, self.colors['attrval']) # after the opening double quote in an attribute value elif state == State_DoubleQuote: start = pos while pos < len_: ch = text[pos] pos += 1 if ch == '"': break state = State_InsideTag self.setFormat(start, pos - start, self.colors['attrval']) else: # State_Text and default while pos < len_: ch = text[pos] if ch == '<': if text[pos:pos+4] == "<!--": state = State_Comment else: if text[pos:pos+9].upper() == "<!DOCTYPE": state = State_DocType else: state = State_TagStart break elif ch == '&': start = pos while pos < len_ and text[pos] != ';': self.setFormat(start, pos - start, self.colors['entity']) pos += 1 else: pos += 1 self.setCurrentBlockState(state) # }}} class Editor(QWidget): # {{{ toolbar_prefs_name = None data_changed = pyqtSignal() def __init__(self, parent=None, one_line_toolbar=False, toolbar_prefs_name=None): QWidget.__init__(self, parent) self.toolbar_prefs_name = toolbar_prefs_name or self.toolbar_prefs_name self.toolbar = create_flow_toolbar(self, restrict_to_single_line=one_line_toolbar, icon_size=18) self.editor = EditorWidget(self) self.editor.data_changed.connect(self.data_changed) self.set_base_url = self.editor.set_base_url self.set_html = self.editor.set_html self.tabs = QTabWidget(self) self.tabs.setTabPosition(QTabWidget.TabPosition.South) self.wyswyg = QWidget(self.tabs) self.code_edit = QPlainTextEdit(self.tabs) self.source_dirty = False self.wyswyg_dirty = True self._layout = QVBoxLayout(self) self.wyswyg.layout = l = QVBoxLayout(self.wyswyg) self.setLayout(self._layout) l.setContentsMargins(0, 0, 0, 0) l.addWidget(self.toolbar) l.addWidget(self.editor) self._layout.addWidget(self.tabs) self.tabs.addTab(self.wyswyg, _('&Normal view')) self.tabs.addTab(self.code_edit, _('&HTML source')) self.tabs.currentChanged[int].connect(self.change_tab) self.highlighter = Highlighter(self.code_edit.document()) self.layout().setContentsMargins(0, 0, 0, 0) if self.toolbar_prefs_name is not None: hidden = gprefs.get(self.toolbar_prefs_name) if hidden: self.hide_toolbars() self.toolbar.add_action(self.editor.action_undo) self.toolbar.add_action(self.editor.action_redo) self.toolbar.add_action(self.editor.action_select_all) self.toolbar.add_action(self.editor.action_remove_format) self.toolbar.add_action(self.editor.action_clear) self.toolbar.add_separator() for x in ('copy', 'cut', 'paste'): ac = getattr(self.editor, 'action_'+x) self.toolbar.add_action(ac) self.toolbar.add_separator() self.toolbar.add_action(self.editor.action_background) self.toolbar.add_action(self.editor.action_color) self.toolbar.add_separator() for x in ('', 'un'): ac = getattr(self.editor, 'action_%sordered_list'%x) self.toolbar.add_action(ac) self.toolbar.add_separator() for x in ('superscript', 'subscript', 'indent', 'outdent'): self.toolbar.add_action(getattr(self.editor, 'action_' + x)) if x in ('subscript', 'outdent'): self.toolbar.add_separator() self.toolbar.add_action(self.editor.action_block_style, popup_mode=QToolButton.ToolButtonPopupMode.InstantPopup) self.toolbar.add_action(self.editor.action_insert_link) self.toolbar.add_action(self.editor.action_insert_hr) self.toolbar.add_separator() for x in ('bold', 'italic', 'underline', 'strikethrough'): ac = getattr(self.editor, 'action_'+x) self.toolbar.add_action(ac) self.addAction(ac) self.toolbar.add_separator() for x in ('left', 'center', 'right', 'justified'): ac = getattr(self.editor, 'action_align_'+x) self.toolbar.add_action(ac) self.toolbar.add_separator() QTimer.singleShot(0, self.toolbar.updateGeometry) self.code_edit.textChanged.connect(self.code_dirtied) self.editor.data_changed.connect(self.wyswyg_dirtied) def set_minimum_height_for_editor(self, val): self.editor.setMinimumHeight(val) @property def html(self): self.tabs.setCurrentIndex(0) return self.editor.html @html.setter def html(self, v): self.editor.html = v def change_tab(self, index): # print 'reloading:', (index and self.wyswyg_dirty) or (not index and # self.source_dirty) if index == 1: # changing to code view if self.wyswyg_dirty: self.code_edit.setPlainText(self.editor.html) self.wyswyg_dirty = False elif index == 0: # changing to wyswyg if self.source_dirty: self.editor.html = to_plain_text(self.code_edit) self.source_dirty = False @property def tab(self): return 'code' if self.tabs.currentWidget() is self.code_edit else 'wyswyg' @tab.setter def tab(self, val): self.tabs.setCurrentWidget(self.code_edit if val == 'code' else self.wyswyg) def wyswyg_dirtied(self, *args): self.wyswyg_dirty = True def code_dirtied(self, *args): self.source_dirty = True def hide_toolbars(self): self.toolbar.setVisible(False) def show_toolbars(self): self.toolbar.setVisible(True) QTimer.singleShot(0, self.toolbar.updateGeometry) def toggle_toolbars(self): visible = self.toolbars_visible getattr(self, ('hide' if visible else 'show') + '_toolbars')() if self.toolbar_prefs_name is not None: gprefs.set(self.toolbar_prefs_name, visible) @property def toolbars_visible(self): return self.toolbar.isVisible() @toolbars_visible.setter def toolbars_visible(self, val): getattr(self, ('show' if val else 'hide') + '_toolbars')() def set_readonly(self, what): self.editor.set_readonly(what) def hide_tabs(self): self.tabs.tabBar().setVisible(False) def smarten_punctuation(self): from calibre.ebooks.conversion.preprocess import smarten_punctuation html = self.html newhtml = smarten_punctuation(html) if html != newhtml: self.html = newhtml # }}} if __name__ == '__main__': from calibre.gui2 import Application app = Application([]) w = Editor(one_line_toolbar=False) w.resize(800, 600) w.setWindowFlag(Qt.WindowType.Dialog) w.show() w.html = '''<h1>Test Heading</h1><blockquote>Test blockquote</blockquote><p><span style="background-color: rgb(0, 255, 255); ">He hadn't set <u>out</u> to have an <em>affair</em>, <span style="font-style:italic; background-color:red"> much</span> less a <s>long-term</s>, <b>devoted</b> one.</span><p>hello''' w.html = '<div><p id="moo">Testing <em>a</em> link.</p><p>\xa0</p><p>ss</p></div>' app.exec() # print w.html