#!/usr/bin/env python
# License: GPLv3 Copyright: 2010, Kovid Goyal <kovid at kovidgoyal.net>

import os
import re
import sys
import weakref
from collections import defaultdict
from collections.abc import Callable
from contextlib import contextmanager
from functools import partial
from threading import Thread
from typing import cast

from html5_parser import parse
from lxml import html
from qt.core import (
    QAction,
    QBrush,
    QByteArray,
    QCheckBox,
    QColor,
    QColorDialog,
    QDialog,
    QDialogButtonBox,
    QFont,
    QFontInfo,
    QFontMetrics,
    QFormLayout,
    QHBoxLayout,
    QIcon,
    QKeySequence,
    QLabel,
    QLineEdit,
    QMenu,
    QPalette,
    QPointF,
    QPushButton,
    QSize,
    QSpinBox,
    QSyntaxHighlighter,
    Qt,
    QTabWidget,
    QTextBlockFormat,
    QTextCharFormat,
    QTextCursor,
    QTextDocument,
    QTextEdit,
    QTextFormat,
    QTextFrameFormat,
    QTextImageFormat,
    QTextListFormat,
    QTimer,
    QToolButton,
    QUrl,
    QVBoxLayout,
    QWidget,
    pyqtSignal,
    pyqtSlot,
)

from calibre import browser, fit_image, xml_replace_entities
from calibre.db.constants import DATA_DIR_NAME
from calibre.ebooks.chardet import xml_to_unicode
from calibre.gui2 import (
    NO_URL_FORMATTING,
    FunctionDispatcher,
    choose_dir,
    choose_files,
    error_dialog,
    gprefs,
    is_dark_theme,
    local_path_for_resource,
    qapplication_or_fail,
    question_dialog,
    safe_open_url,
)
from calibre.gui2.book_details import resolved_css
from calibre.gui2.dialogs.progress import ProgressDialog
from calibre.gui2.flow_toolbar import create_flow_toolbar
from calibre.gui2.tweak_book.widgets import PlainTextEdit
from calibre.gui2.widgets import LineEditECM
from calibre.gui2.widgets2 import to_plain_text
from calibre.startup import connect_lambda
from calibre.utils.cleantext import clean_xml_chars
from calibre.utils.config import tweaks
from calibre.utils.filenames import make_long_path_useable
from calibre.utils.imghdr import what
from calibre.utils.localization import _

# Cleanup Qt markup {{{

OBJECT_REPLACEMENT_CHAR = '\ufffc'


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 and child.tail.strip():
            has_text = True
        style = style_map[child]
        child_styles.append(style)
        if not child.text and len(child) == 0:
            continue
        if common_props is None:
            common_props = style.copy()
        else:
            for k, v in tuple(common_props.items()):
                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:
                    style.pop(k, None)


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') in ('0', '600', '700', 'bold'):
        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') in ('600', '700', 'bold')
    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('//a[@href=$h]', h=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'))
    convert_anchors_to_ids(root)
    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 img in root.xpath('//img[@style]'):
        s = style_map.get(img)
        if s:
            if s == {'float': 'left'}:
                s['margin-right'] = '0.5em'
            elif s == {'float': 'right'}:
                s['margin-left'] = '0.5em'
    for style in style_map.values():
        filter_qt_styles(style)
        fw = style.get('font-weight')
        if fw in ('600', '700'):
            style['font-weight'] = 'bold'
    for tag, style in style_map.items():
        if style:
            tag.set('style', '; '.join(f'{k}: {v}' for k, v in style.items()))
        else:
            tag.attrib.pop('style', None)
    for span in root.xpath('//span[not(@style)]'):
        lift(span)

    merge_contiguous_links(root)


# }}}


def fix_html(original_html, original_txt, remove_comments=True, callback=None):
    raw = original_html
    raw = xml_to_unicode(raw, strip_encoding_pats=True, resolve_entities=True)[0]
    if remove_comments:
        comments_pat = re.compile(r'<!--.*?-->', re.DOTALL)
        raw = comments_pat.sub('', raw)
    if not original_txt 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()
    if callback is not None:
        callback(root, original_txt)
    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>{}</div>'.format(''.join(elems))
    else:
        ans = ''.join(elems)
        if not ans.startswith('<'):
            ans = f'<p>{ans}</p>'
    return xml_replace_entities(ans)


class BlockAction(QAction):
    block_name: str = ''


class EditorWidget(QTextEdit, LineEditECM):  # {{{
    data_changed = pyqtSignal()
    insert_images_separately = False
    can_store_images = False

    action_bold: QAction
    action_italic: QAction
    action_underline: QAction
    action_strikethrough: QAction
    action_superscript: QAction
    action_subscript: QAction
    action_ordered_list: QAction
    action_unordered_list: QAction
    action_align_left: QAction
    action_align_center: QAction
    action_align_right: QAction
    action_align_justified: QAction
    action_undo: QAction
    action_redo: QAction
    action_remove_format: QAction
    action_copy: QAction
    action_paste: QAction
    action_paste_and_match_style: QAction
    action_cut: QAction
    action_indent: QAction
    action_outdent: QAction
    action_select_all: QAction
    action_color: QAction
    action_background: QAction
    action_insert_link: QAction
    action_insert_image: QAction
    action_insert_hr: QAction
    action_clear: QAction
    action_upper_case: QAction
    action_lower_case: QAction
    action_capitalize: QAction
    action_swap_case: QAction
    action_title_case: QAction
    action_block_style: QAction

    @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)
        doc = self.document()
        assert doc is not None
        doc.setDefaultStyleSheet(resolved_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(int(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.shortcut_map = {}

        def r(name, icon, text, checkable=False, shortcut=None, callback=None):
            ac = QAction(QIcon.ic(icon + '.png'), text, self) if icon else QAction(text, self)
            ac.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
            if checkable:
                ac.setCheckable(checkable)
            setattr(self, 'action_' + name, ac)
            callback = callback or getattr(self, 'do_' + name)
            ac.triggered.connect(callback)
            if shortcut is not None:
                if isinstance(shortcut, str):
                    shortcut = QKeySequence(shortcut, QKeySequence.SequenceFormat.PortableText)
                self.shortcut_map[shortcut] = ac
                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'), shortcut='ctrl+shift+v')
        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') if self.insert_images_separately else _('Insert link or image'),
            shortcut='Ctrl+l',
        )
        r('insert_image', 'view-image', _('Insert image'), shortcut='Ctrl+p')
        r(
            'insert_hr',
            'format-text-hr',
            _('Insert separator'),
        )
        r('clear', 'trash', _('Clear'))
        r('upper_case', '', _('Upper case'), shortcut='Ctrl+alt+u', callback=self.upper_case)
        r('lower_case', '', _('Lower case'), shortcut='Ctrl+alt+l', callback=self.lower_case)
        r('capitalize', '', _('Capitalize'), shortcut='Ctrl+alt+c', callback=self.capitalize)
        r('swap_case', '', _('Swap case'), shortcut='Ctrl+alt+s', callback=self.swap_case)
        r('title_case', '', _('Title case'), shortcut='Ctrl+alt+t', callback=self.title_case)

        self.action_block_style = QAction(QIcon.ic('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 = BlockAction(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 keyPressEvent(self, e):
        for sc, ac in self.shortcut_map.items():
            if isinstance(sc, QKeySequence.StandardKey) and e.matches(sc):
                ac.trigger()
                return
        return super().keyPressEvent(e)

    def do_copy(self):
        self.copy()
        self.focus_self()

    def do_paste(self):
        images_before = set()
        doc = self.document()
        assert doc is not None
        for fmt in doc.allFormats():
            if fmt.isImageFormat():
                images_before.add(fmt.toImageFormat().name())
        self.paste()
        if self.can_store_images:
            added = set()
            for fmt in doc.allFormats():
                if fmt.isImageFormat():
                    name = fmt.toImageFormat().name()
                    if name not in images_before and name.partition(':')[0] in ('https', 'http'):
                        added.add(name)
            if added and question_dialog(self, _('Download images'), _('Download all remote images in the pasted content?')):
                self.download_images(added)
        self.focus_self()

    def commit_downloaded_image(self, data: bytes, suggested_filename: str) -> str:
        raise NotImplementedError

    def download_images(self, urls):
        from calibre.web import get_download_filename_from_response

        br = browser()
        d = self.document()
        assert d is not None
        c = self.textCursor()
        c.setPosition(0)
        pos_map = {}
        while True:
            c = d.find(OBJECT_REPLACEMENT_CHAR, c, QTextDocument.FindFlag.FindCaseSensitively)
            if c.isNull():
                break
            fmt = c.charFormat()
            if fmt.isImageFormat():
                name = fmt.toImageFormat().name()
                if name in urls:
                    pos_map[name] = c.position()

        pd = ProgressDialog(title=_('Downloading images...'), min=0, max=0, parent=self)
        pd.canceled_signal.connect(pd.accept)
        data_map = {}
        error_map = {}
        set_msg = FunctionDispatcher(pd.set_msg, parent=pd)
        accept = FunctionDispatcher(pd.accept, parent=pd)

        def do_download():
            for url, pos in pos_map.items():
                if pd.canceled:
                    return
                set_msg(_('Downloading {}...').format(url))
                try:
                    res = br.open_novisit(url, timeout=30)
                    fname = get_download_filename_from_response(res)
                    data = res.read()
                except Exception as err:
                    error_map[url] = err
                else:
                    data_map[url] = fname, data
            accept()

        Thread(target=do_download, daemon=True).start()
        pd.exec()
        if pd.canceled:
            return

        for url, pos in pos_map.items():
            fname, data = data_map[url]
            newname = self.commit_downloaded_image(data, fname)
            c = self.textCursor()
            c.setPosition(pos)
            alignment = QTextFrameFormat.Position.InFlow
            f = self.frame_for_cursor(c)
            if f is not None:
                alignment = f.frameFormat().position()
            fmt = c.charFormat()
            i = fmt.toImageFormat()
            i.setName(newname)
            c.deletePreviousChar()
            c.insertImage(i, alignment)
        if error_map:
            m = '\n'.join(_('Could not download {0} with error:').format(url) + '\n\t' + str(err) + '\n\n' for url, err in error_map.items())
            error_dialog(
                self,
                _('Failed to download some images'),
                _('Some images could not be downloaded, click "Show details" to see which ones'),
                det_msg=m,
                show=True,
            )

    def do_paste_and_match_style(self):
        clipboard = qapplication_or_fail().clipboard()
        assert clipboard is not None
        text = 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):
        sender = self.sender()
        assert isinstance(sender, BlockAction)
        name = 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 QFont.Weight.Normal
            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)
            bcf.setProperty(QTextFormat.Property.FontSizeAdjustment, adjust)
            cf.setProperty(QTextFormat.Property.FontSizeAdjustment, adjust)
            bcf.setFontWeight(wt)
            cf.setFontWeight(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_image(self):
        from calibre.gui2 import choose_images

        files = choose_images(self, 'choose-image-for-comments-editor', _('Choose image'), formats='png jpeg jpg gif svg webp'.split())
        if files:
            self.focus_self()
            with self.editing_cursor() as c:
                c.insertImage(files[0])

    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():
            if url.isLocalFile() and not os.path.isabs(url.toLocalFile()):
                url = url.toLocalFile()
            else:
                url = 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):
            url: QLineEdit
            name: QLineEdit
            treat_as_image: QCheckBox
            bb: QDialogButtonBox
            br: QPushButton
            brdf: QPushButton
            brd: QPushButton
            la: QLabel

            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(_('&File'))
        base = os.path.dirname(self.base_url.toLocalFile()) if self.base_url else os.getcwd()
        data_path = os.path.join(base, DATA_DIR_NAME)
        if self.base_url:
            os.makedirs(data_path, exist_ok=True)

        def cf(data_dir=False):
            filetypes = []
            if d.treat_as_image.isChecked():
                filetypes = [(_('Images'), 'png jpeg jpg gif'.split())]
            if data_dir:
                files = choose_files(
                    d,
                    'select link file',
                    _('Choose file'),
                    filetypes,
                    select_only_single_file=True,
                    no_save_dir=True,
                    default_dir=data_path,
                )
            else:
                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 open(path, 'rb') as f:
                        q = what(f)
                    is_image = q in {'jpeg', 'png', 'gif', 'webp'}
                    d.treat_as_image.setChecked(is_image)
                if data_dir:
                    path = os.path.relpath(path, base).replace(os.sep, '/')
                    d.url.setText(path)

        b.clicked.connect(cf)
        d.brdf = b = QPushButton(_('&Data file'))
        b.clicked.connect(lambda: cf(True))
        b.setToolTip(_('A relative link to a data file associated with this book'))
        if not os.path.exists(data_path):
            b.setVisible(False)
        d.brd = b = QPushButton(_('F&older'))

        def cd():
            path = choose_dir(d, 'select link folder', _('Choose folder'))
            if path:
                d.url.setText(path)

        b.clicked.connect(cd)

        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)
        h = QHBoxLayout()
        h.addWidget(d.br), h.addWidget(d.brdf), h.addWidget(d.brd)
        l.addRow(_('Choose a file on your computer:'), h)
        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
        else:
            if self.base_url:
                base = os.path.dirname(self.base_url.toLocalFile())
            else:
                base = os.getcwd()
            candidate = os.path.join(base, link)
            if os.path.exists(candidate):
                return QUrl.fromLocalFile(link)
        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):
        return fix_html(self.toHtml(), self.toPlainText().strip(), callback=self.get_html_callback)

    def get_html_callback(self, root, text):
        pass

    @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, type, name):
        path = local_path_for_resource(name, base_qurl=self.base_url)
        if path:
            data = None
            try:
                with open(make_long_path_useable(path), 'rb') as f:
                    data = f.read()
            except OSError:
                if path.rpartition('.')[-1].lower() in {'jpg', 'jpeg', 'gif', 'png', 'bmp', 'webp'}:
                    data = bytearray.fromhex(
                        '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c63000100000500010d0a2db40000000049454e44ae426082'
                    )
            if data is not None:
                r = QByteArray(data)
                doc = self.document()
                assert doc is not None
                doc.addResource(type, name, r)
                return r

    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) -> str:
        return self.textCursor().selectedText()

    def selectedText(self) -> str:
        return self.text()

    def setText(self, text: str | None) -> None:
        with self.editing_cursor() as c:
            c.insertText(text or '')

    def insert(self, a0: str | None) -> None:
        self.setText(a0)

    def hasSelectedText(self) -> bool:
        c = self.textCursor()
        return c.hasSelection()

    def createMimeDataFromSelection(self):
        ans = super().createMimeDataFromSelection()
        assert ans is not None
        html, txt = ans.html(), ans.text()
        html = fix_html(html, txt, remove_comments=False)
        # Qt has a bug where copying from the start of a paragraph does not
        # include the paragraph definition in the fragment. Try to fix that
        # by moving the StartFragment comment to before the paragraph when
        # the selection starts at the start of a block
        c = self.textCursor()
        c2 = QTextCursor(c)
        c2.setPosition(c.selectionStart())
        if c2.atBlockStart():
            html = re.sub(r'(<p.*?>)(<!--StartFragment-->)', r'\2\1', html, count=1)
        ans.setHtml(html)
        return ans

    def remove_image_at(self, cursor_pos):
        c = self.textCursor()
        c.clearSelection()
        c.setPosition(cursor_pos)
        fmt = c.charFormat()
        if fmt.isImageFormat():
            c.deletePreviousChar()

    def resize_image_at(self, cursor_pos):
        c = self.textCursor()
        c.clearSelection()
        c.setPosition(cursor_pos)
        c.movePosition(QTextCursor.MoveOperation.PreviousCharacter, QTextCursor.MoveMode.KeepAnchor)
        fmt = c.charFormat()
        if not fmt.isImageFormat():
            return
        fmt = fmt.toImageFormat()
        from calibre.utils.img import image_from_data

        img = image_from_data(self.loadResource(QTextDocument.ResourceType.ImageResource, QUrl(fmt.name())))
        w, h = int(fmt.width()), int(fmt.height())
        d = QDialog(self)
        l = QVBoxLayout(d)
        la = QLabel(_('Shrink image to fit within:'))
        h = QHBoxLayout()
        l.addLayout(h)
        la = QLabel(_('&Width:'))
        h.addWidget(la)
        width_spinbox = w = QSpinBox(self)
        w.setRange(0, 10000), w.setSuffix(' px')
        w.setValue(int(fmt.width()))
        h.addWidget(w), la.setBuddy(w)
        w.setSpecialValueText(' ')
        la = QLabel(_('&Height:'))
        h.addWidget(la)
        height_spinbox = w = QSpinBox(self)
        w.setRange(0, 10000), w.setSuffix(' px')
        w.setValue(int(fmt.height()))
        h.addWidget(w), la.setBuddy(w)
        w.setSpecialValueText(' ')
        h.addStretch(10)
        bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        bb.accepted.connect(d.accept), bb.rejected.connect(d.reject)
        d.setWindowTitle(_('Enter new size for image'))
        l.addWidget(bb)
        d.resize(d.sizeHint())

        if d.exec() == QDialog.DialogCode.Accepted:
            page_width, page_height = (width_spinbox.value() or sys.maxsize), (height_spinbox.value() or sys.maxsize)
            w, h = int(img.width()), int(img.height())
            resized, nw, nh = fit_image(w, h, page_width, page_height)
            if resized:
                fmt.setWidth(nw), fmt.setHeight(nh)
            else:
                f = QTextImageFormat()
                f.setName(fmt.name())
                fmt = f
            c.setCharFormat(fmt)

    def align_image_at(self, cursor_pos, alignment):
        c = self.textCursor()
        c.clearSelection()
        c.setPosition(cursor_pos)
        fmt = c.charFormat()
        if fmt.isImageFormat() and c.currentFrame():
            f = self.frame_for_cursor(c)
            if f is not None:
                ff = f.frameFormat()
                ff.setPosition(alignment)
                f.setFrameFormat(ff)
                doc = self.document()
                assert doc is not None
                doc.markContentsDirty(cursor_pos - 2, 5)
            else:
                c.deleteChar()
                c.insertImage(fmt.toImageFormat(), alignment)

    def first_image_replacement_char_position_for(self, image_name):
        d = self.document()
        assert d is not None
        c = self.textCursor()
        c.setPosition(0)
        while True:
            c = d.find(OBJECT_REPLACEMENT_CHAR, c, QTextDocument.FindFlag.FindCaseSensitively)
            if c.isNull():
                break
            fmt = c.charFormat()
            if fmt.isImageFormat() and fmt.toImageFormat().name() == image_name:
                return c.position()
        return -1

    def frame_for_cursor(self, c):
        q = c.position()
        for cf in c.currentFrame().childFrames():
            a, b = cf.firstPosition(), cf.lastPosition()
            a, b = min(a, b), max(a, b)
            if a <= q <= b:
                return cf

    def open_link(self, link: str) -> None:
        name = QUrl(link, QUrl.ParsingMode.TolerantMode)
        if name.isRelative() and self.base_url:
            name = QUrl.fromLocalFile(os.path.join(os.path.dirname(self.base_url.toLocalFile()), name.path()))
        safe_open_url(name)

    def contextMenuEvent(self, e):
        menu = QMenu(self)
        doc = self.document()
        assert doc is not None
        doc_layout = doc.documentLayout()
        assert doc_layout is not None
        img_name = doc_layout.imageAt(QPointF(e.pos()))
        if img_name:
            pos = self.first_image_replacement_char_position_for(img_name)
            if pos > -1:
                c = self.textCursor()
                c.clearSelection()
                c.setPosition(pos)
                pos = QTextFrameFormat.Position.InFlow
                ff = self.frame_for_cursor(c)
                if ff is not None:
                    pos = ff.frameFormat().position()
                align_menu = menu.addMenu(QIcon.ic('view-image.png'), _('Image...'))
                assert align_menu is not None

                def a(text, epos):
                    ac = align_menu.addAction(text)
                    ac.setCheckable(True)
                    ac.triggered.connect(partial(self.align_image_at, c.position(), epos))
                    if pos == epos:
                        ac.setChecked(True)

                cs = align_menu.addAction(QIcon.ic('resize.png'), _('Change size'))
                assert cs is not None
                cs.triggered.connect(partial(self.resize_image_at, c.position()))
                align_menu.addSeparator()
                a(_('Float to the left'), QTextFrameFormat.Position.FloatLeft)
                a(_('Inline with text'), QTextFrameFormat.Position.InFlow)
                a(_('Float to the right'), QTextFrameFormat.Position.FloatRight)
                align_menu.addSeparator()
                trash_action = align_menu.addAction(QIcon.ic('trash.png'), _('Remove this image'))
                assert trash_action is not None
                trash_action.triggered.connect(partial(self.remove_image_at, c.position()))
        link_name = doc_layout.anchorAt(QPointF(e.pos()))
        if link_name:
            menu.addAction(QIcon.ic('insert-link.png'), _('Open link'), partial(self.open_link, link_name))
        for ac in 'undo redo -- cut copy paste paste_and_match_style -- select_all'.split():
            if ac == '--':
                menu.addSeparator()
            else:
                ac = getattr(self, 'action_' + ac)
                menu.addAction(ac)
        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():
            m = QMenu(_('Change case'), menu)
            m.addAction(self.action_upper_case)
            m.addAction(self.action_lower_case)
            m.addAction(self.action_swap_case)
            m.addAction(self.action_title_case)
            m.addAction(self.action_capitalize)
            menu.addMenu(m)
        parent = self._parent()
        assert parent is not None
        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)
        if self.insert_images_separately:
            am.addAction(self.action_insert_image)
        am.addAction(self.action_background)
        am.addAction(self.action_color)
        menu.addAction(_('Smarten punctuation'), parent.smarten_punctuation)
        menu.exec(e.globalPos())

    def modify_case_operation(self, func: Callable[[str], str]) -> None:
        cursor: QTextCursor = self.textCursor()
        if not cursor.hasSelection():
            cursor.select(QTextCursor.SelectionType.Document)
        start = cursor.selectionStart()
        end = cursor.selectionEnd()
        doc = self.document()

        # Helper: collect blocks intersecting the selection
        blocks = []

        # Create a cursor at start to find the first block
        sc = QTextCursor(doc)
        sc.setPosition(start)
        first_block = sc.block()

        block = first_block
        while block.isValid() and block.position() < end:
            block_start = block.position()
            block_len = block.length()  # includes block separator
            block_end = block_start + block_len
            seg_start = max(start, block_start)
            seg_end = min(end, block_end)
            if seg_start >= seg_end:
                block = block.next()
                continue

            # record block-level formatting (block format and list format if any)
            bf = QTextBlockFormat(block.blockFormat())
            list_format = None
            blist = block.textList()
            if blist is not None:
                list_format = QTextListFormat(blist.format())

            # iterate fragments in the block and extract only the overlapped text pieces
            fragments = []
            fragment_text = ''
            it = block.begin()
            while not it.atEnd():
                frag = it.fragment()
                if frag.isValid():
                    fpos = frag.position()
                    flen = frag.length()
                    fend = fpos + flen
                    ov_start = max(seg_start, fpos)
                    ov_end = min(seg_end, fend)
                    if ov_start < ov_end:
                        rel_start = ov_start - fpos
                        rel_len = ov_end - ov_start
                        text = frag.text()[rel_start : rel_start + rel_len]
                        fmt = QTextCharFormat(frag.charFormat())
                        fragments.append((len(fragment_text), len(text), fmt))
                        fragment_text += text
                it += 1

            blocks.append({
                'blockFormat': bf,
                'listFormat': list_format,
                'blockStart': block_start,
                'fragments': fragments,
                'fragment_text': fragment_text,
            })

            block = block.next()

        if not blocks:
            return

        # Replace the selection preserving inline and block formats.
        editcur = QTextCursor(doc)
        editcur.setPosition(start)
        editcur.setPosition(end, QTextCursor.MoveMode.KeepAnchor)

        editcur.beginEditBlock()
        editcur.removeSelectedText()
        # after removal the cursor is at `start`
        editcur.setPosition(start)

        # Insert blocks and fragments. Handle first block specially to avoid unwanted new block
        for idx, blk in enumerate(blocks):
            blk_fmt = cast(QTextBlockFormat, blk['blockFormat'])
            list_fmt = blk['listFormat']

            # Determine if original first block was partial (selection started mid-block).
            if idx == 0:
                # keep and reuse the current block — apply its block format/list
                editcur.setBlockFormat(blk_fmt)
            else:
                # subsequent blocks -> always insert a new block with the original format
                editcur.insertBlock(blk_fmt)
            if isinstance(list_fmt, QTextListFormat):
                editcur.createList(list_fmt)

            fragment_text = func(cast(str, blk['fragment_text']))
            # insert the fragments for this block, preserving char formats
            for start_pos, length, ch_fmt in cast(list[tuple[int, int, QTextCharFormat]], blk['fragments']):
                if start_pos >= len(fragment_text):
                    break
                # Convert selected fragment text
                # (paragraph separators are not part of per-fragment text here)
                editcur.insertText(fragment_text[start_pos : start_pos + length], ch_fmt)

        editcur.endEditBlock()

        # Reselect the replaced text so selection remains
        new_end = editcur.position()
        new_cursor = self.textCursor()
        new_cursor.setPosition(start)
        new_cursor.setPosition(new_end, QTextCursor.MoveMode.KeepAnchor)
        self.setTextCursor(new_cursor)


# }}}

# 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
                        elif 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()
    editor_class = EditorWidget

    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 = self.editor_class(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 = PlainTextEdit(self.tabs)
        self.code_edit.setTabChangesFocus(True)
        self.source_dirty = False
        self.wyswyg_dirty = True

        self._layout = QVBoxLayout(self)
        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())
        layout = self.layout()
        assert layout is not None
        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, f'action_{x}ordered_list')
            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)
        if self.editor.insert_images_separately:
            self.toolbar.add_action(self.editor.action_insert_image)
        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):
        tab_bar = self.tabs.tabBar()
        assert tab_bar is not None
        tab_bar.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.set_base_url(QUrl.fromLocalFile(os.getcwd()))
    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'''
    i = 'file:///home/kovid/work/calibre/resources/images/'
    w.html = f'<p>Testing <img src="{i}/donate.png"> img and another <img src="{i}/lt.png">file</p>'
    w.html = '<div><p id="moo" align="justify">Testing&nbsp;<em>a</em> link.</p><p align="justify">&nbsp;</p><p align="justify">ss</p></div>'
    app.exec()
    print(repr(w.html))
