diff --git a/src/calibre/ebooks/oeb/polish/spell.py b/src/calibre/ebooks/oeb/polish/spell.py index 7fc7542ba7..db06be6fa3 100644 --- a/src/calibre/ebooks/oeb/polish/spell.py +++ b/src/calibre/ebooks/oeb/polish/spell.py @@ -157,12 +157,16 @@ def group_sort(locations): order[loc.file_name] = len(order) return sorted(locations, key=lambda l:(order[l.file_name], l.sourceline)) -def get_all_words(container, book_locale): - words = defaultdict(list) +def get_checkable_file_names(container): file_names = [name for name, linear in container.spine_names] + [container.opf_name] toc = find_existing_toc(container) if toc is not None and container.exists(toc): file_names.append(toc) + return file_names, toc + +def get_all_words(container, book_locale): + words = defaultdict(list) + file_names, toc = get_checkable_file_names(container) for file_name in file_names: if not container.exists(file_name): continue diff --git a/src/calibre/gui2/tweak_book/__init__.py b/src/calibre/gui2/tweak_book/__init__.py index c07d2baecb..0fe95e3c18 100644 --- a/src/calibre/gui2/tweak_book/__init__.py +++ b/src/calibre/gui2/tweak_book/__init__.py @@ -46,6 +46,7 @@ d['disable_completion_popup_for_search'] = False d['saved_searches'] = [] d['insert_tag_mru'] = ['p', 'div', 'li', 'h1', 'h2', 'h3', 'h4', 'em', 'strong', 'td', 'tr'] d['spell_check_case_sensitive_sort'] = False +d['inline_spell_check'] = True del d diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index 578ec79e0b..648fb2cb08 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -39,7 +39,7 @@ from calibre.gui2.tweak_book.editor import editor_from_syntax, syntax_from_mime from calibre.gui2.tweak_book.editor.insert_resource import get_resource_data, NewBook from calibre.gui2.tweak_book.preferences import Preferences from calibre.gui2.tweak_book.search import validate_search_request, run_search -from calibre.gui2.tweak_book.spell import find_next as find_next_word +from calibre.gui2.tweak_book.spell import find_next as find_next_word, find_next_error from calibre.gui2.tweak_book.widgets import ( RationalizeFolders, MultiSplit, ImportForeign, QuickOpen, InsertLink, InsertSemantics, BusyCursor, InsertTag, FilterCSS) @@ -116,10 +116,12 @@ class Boss(QObject): self.gui.spell_check.find_word.connect(self.find_word) self.gui.spell_check.refresh_requested.connect(self.commit_all_editors_to_container) self.gui.spell_check.word_replaced.connect(self.word_replaced) + self.gui.spell_check.word_ignored.connect(self.word_ignored) def preferences(self): p = Preferences(self.gui) ret = p.exec_() + orig_spell = tprefs['inline_spell_check'] if p.dictionaries_changed: dictionaries.clear_caches() dictionaries.initialize(force=True) # Reread user dictionaries @@ -129,6 +131,12 @@ class Boss(QObject): if ret == p.Accepted or p.dictionaries_changed: for ed in editors.itervalues(): ed.apply_settings(dictionaries_changed=p.dictionaries_changed) + if orig_spell != tprefs['inline_spell_check']: + for ed in editors.itervalues(): + try: + ed.editor.highlighter.rehighlight() + except AttributeError: + pass def mark_requested(self, name, action): self.commit_dirty_opf() @@ -740,10 +748,28 @@ class Boss(QObject): break find_next_word(word, locations, ed, name, self.gui, self.show_editor, self.edit_file) + def next_spell_error(self): + ' Go to the next spelling error ' + ed = self.gui.central.current_editor + name = None + for n, x in editors.iteritems(): + if x is ed: + name = n + break + find_next_error(ed, name, self.gui, self.show_editor, self.edit_file) + def word_replaced(self, changed_names): self.set_modified() self.update_editors_from_container(names=set(changed_names)) + def word_ignored(self, word, locale): + if tprefs['inline_spell_check']: + for ed in editors.itervalues(): + try: + ed.editor.recheck_word(word, locale) + except AttributeError: + pass + def saved_searches(self): self.gui.saved_searches.show(), self.gui.saved_searches.raise_() @@ -1042,6 +1068,8 @@ class Boss(QObject): editor.copy_available_state_changed.connect(self.editor_copy_available_state_changed) editor.cursor_position_changed.connect(self.sync_preview_to_editor) editor.cursor_position_changed.connect(self.update_cursor_position) + if hasattr(editor, 'word_ignored'): + editor.word_ignored.connect(self.word_ignored) if data is not None: if use_template: editor.init_from_template(data) diff --git a/src/calibre/gui2/tweak_book/editor/__init__.py b/src/calibre/gui2/tweak_book/editor/__init__.py index a16cc7fae6..0f4f09b9bd 100644 --- a/src/calibre/gui2/tweak_book/editor/__init__.py +++ b/src/calibre/gui2/tweak_book/editor/__init__.py @@ -34,6 +34,7 @@ def editor_from_syntax(syntax, parent=None): SYNTAX_PROPERTY = QTextCharFormat.UserProperty +SPELL_PROPERTY = SYNTAX_PROPERTY + 1 class SyntaxTextCharFormat(QTextCharFormat): diff --git a/src/calibre/gui2/tweak_book/editor/syntax/base.py b/src/calibre/gui2/tweak_book/editor/syntax/base.py index 191eb06eb2..f8075bcbf0 100644 --- a/src/calibre/gui2/tweak_book/editor/syntax/base.py +++ b/src/calibre/gui2/tweak_book/editor/syntax/base.py @@ -151,6 +151,10 @@ class SyntaxHighlighter(object): finally: doc.contentsChange.connect(self.reformat_blocks) + def reformat_block(self, block): + if block.isValid(): + self.reformat_blocks(block.position(), 0, 1) + def apply_format_changes(self, doc, block, formats): layout = block.layout() preedit_start = layout.preeditAreaPosition() diff --git a/src/calibre/gui2/tweak_book/editor/syntax/html.py b/src/calibre/gui2/tweak_book/editor/syntax/html.py index 9d92394e70..e117943e88 100644 --- a/src/calibre/gui2/tweak_book/editor/syntax/html.py +++ b/src/calibre/gui2/tweak_book/editor/syntax/html.py @@ -14,7 +14,9 @@ from PyQt4.Qt import QFont, QTextBlockUserData from calibre.ebooks.oeb.polish.spell import html_spell_tags, xml_spell_tags from calibre.spell.dictionary import parse_lang_code -from calibre.gui2.tweak_book.editor import SyntaxTextCharFormat +from calibre.spell.break_iterator import split_into_words_and_positions +from calibre.gui2.tweak_book import dictionaries, tprefs +from calibre.gui2.tweak_book.editor import SyntaxTextCharFormat, SPELL_PROPERTY from calibre.gui2.tweak_book.editor.syntax.base import SyntaxHighlighter, run_loop from calibre.gui2.tweak_book.editor.syntax.css import ( create_formats as create_css_formats, state_map as css_state_map, CSSState, CSSUserData) @@ -170,6 +172,16 @@ class HTMLUserData(QTextBlockUserData): self.tags, self.attributes = [], [] self.state = State() if state is None else state + @classmethod + def tag_ok_for_spell(cls, name): + return name not in html_spell_tags + +class XMLUserData(HTMLUserData): + + @classmethod + def tag_ok_for_spell(cls, name): + return name in xml_spell_tags + def add_tag_data(user_data, tag): user_data.tags.append(tag) @@ -211,7 +223,7 @@ def cdata(state, text, i, formats, user_data): add_tag_data(user_data, TagStart(m.start(), '', name, True, True)) return [(num, fmt), (2, formats['end_tag']), (len(m.group()) - 2, formats['tag_name'])] -def mark_nbsp(state, text, nbsp_format): +def process_text(state, text, nbsp_format, spell_format, user_data): ans = [] fmt = None if state.is_bold or state.is_italic: @@ -226,6 +238,36 @@ def mark_nbsp(state, text, nbsp_format): last = m.end() if not ans: ans = [(len(text), fmt)] + + if tprefs['inline_spell_check'] and state.tags and user_data.tag_ok_for_spell(state.tags[-1].name): + split_ans = [] + locale = state.current_lang or dictionaries.default_locale + sfmt = SyntaxTextCharFormat(spell_format) + if fmt is not None: + sfmt.merge(fmt) + + tpos = 0 + for tlen, fmt in ans: + if fmt is nbsp_format: + split_ans.append((tlen, fmt)) + else: + ctext = text[tpos:tpos+tlen] + ppos = 0 + for start, length in split_into_words_and_positions(ctext, lang=locale.langcode): + if start > ppos: + split_ans.append((start - ppos, fmt)) + ppos = start + length + recognized = dictionaries.recognized(ctext[start:ppos], locale) + if not recognized: + wsfmt = SyntaxTextCharFormat(sfmt) + wsfmt.setProperty(SPELL_PROPERTY, (ctext[start:ppos], locale)) + split_ans.append((length, fmt if recognized else wsfmt)) + if ppos == 0: + split_ans.append((tlen, fmt)) + + tpos += tlen + ans = split_ans + return ans def normal(state, text, i, formats, user_data): @@ -277,7 +319,7 @@ def normal(state, text, i, formats, user_data): return [(1, formats['>'])] t = normal_pat.search(text, i).group() - return mark_nbsp(state, t, formats['nbsp']) + return process_text(state, t, formats['nbsp'], formats['spell'], user_data) def opening_tag(cdata_tags, state, text, i, formats, user_data): 'An opening tag, like ' @@ -417,6 +459,7 @@ def create_formats(highlighter, add_css=True): 'nsprefix': t['Constant'], 'preproc': t['PreProc'], 'nbsp': t['SpecialCharacter'], + 'spell': t['SpellError'], } for name, msg in { '<': _('An unescaped < is not allowed. Replace it with <'), @@ -445,18 +488,19 @@ class HTMLHighlighter(SyntaxHighlighter): user_data_factory = HTMLUserData def tag_ok_for_spell(self, name): - return name not in html_spell_tags + return HTMLUserData.tag_ok_for_spell(name) class XMLHighlighter(HTMLHighlighter): state_map = xml_state_map spell_attributes = ('opf:file-as',) + user_data_factory = XMLUserData def create_formats_func(self): return create_formats(self, add_css=False) def tag_ok_for_spell(self, name): - return name in xml_spell_tags + return XMLUserData.tag_ok_for_spell(name) if __name__ == '__main__': from calibre.gui2.tweak_book.editor.widget import launch_editor diff --git a/src/calibre/gui2/tweak_book/editor/text.py b/src/calibre/gui2/tweak_book/editor/text.py index da0ea747f8..96cf4d5b1b 100644 --- a/src/calibre/gui2/tweak_book/editor/text.py +++ b/src/calibre/gui2/tweak_book/editor/text.py @@ -18,7 +18,7 @@ from PyQt4.Qt import ( from calibre import prepare_string_for_xml, xml_entity_to_unicode from calibre.gui2.tweak_book import tprefs, TOP -from calibre.gui2.tweak_book.editor import SYNTAX_PROPERTY +from calibre.gui2.tweak_book.editor import SYNTAX_PROPERTY, SPELL_PROPERTY from calibre.gui2.tweak_book.editor.themes import THEMES, default_theme, theme_color, theme_format from calibre.gui2.tweak_book.editor.syntax.base import SyntaxHighlighter from calibre.gui2.tweak_book.editor.syntax.html import HTMLHighlighter, XMLHighlighter @@ -231,6 +231,11 @@ class TextEdit(PlainTextEdit): self.setTextCursor(c) self.ensureCursorVisible() + def simple_replace(self, text): + c = self.textCursor() + c.insertText(unicodedata.normalize('NFC', text)) + self.setTextCursor(c) + def go_to_line(self, lnum, col=None): lnum = max(1, min(self.blockCount(), lnum)) c = self.textCursor() @@ -383,7 +388,7 @@ class TextEdit(PlainTextEdit): self.saved_matches[save_match] = (pat, m) return True - def find_spell_word(self, original_words, lang, from_cursor=True): + def find_spell_word(self, original_words, lang, from_cursor=True, center_on_cursor=True): c = self.textCursor() c.setPosition(c.position()) if not from_cursor: @@ -407,13 +412,30 @@ class TextEdit(PlainTextEdit): c.setPosition(c.position() + string_length(word), c.KeepAnchor) if self.smarts.verify_for_spellcheck(c, self.highlighter): self.setTextCursor(c) - self.centerCursor() + if center_on_cursor: + self.centerCursor() return True c.setPosition(c.position()) c.movePosition(c.End, c.KeepAnchor) return False + def find_next_spell_error(self, from_cursor=True): + c = self.textCursor() + if not from_cursor: + c.movePosition(c.Start) + block = c.block() + while block.isValid(): + for r in block.layout().additionalFormats(): + if r.format.property(SPELL_PROPERTY).toPyObject() is not None: + if not from_cursor or block.position() + r.start + r.length > c.position(): + c.setPosition(block.position() + r.start) + c.setPosition(c.position() + r.length, c.KeepAnchor) + self.setTextCursor(c) + return True + block = block.next() + return False + def replace(self, pat, template, saved_match='gui'): c = self.textCursor() raw = unicode(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') @@ -546,6 +568,18 @@ class TextEdit(PlainTextEdit): return False return QPlainTextEdit.event(self, ev) + def recheck_word(self, word, locale): + c = self.textCursor() + c.movePosition(c.Start) + block = c.block() + while block.isValid(): + for r in block.layout().additionalFormats(): + x = r.format.property(SPELL_PROPERTY).toPyObject() + if x is not None and word == x[0]: + self.highlighter.reformat_block(block) + break + block = block.next() + # Tooltips {{{ def syntax_format_for_cursor(self, cursor): if cursor.isNull(): diff --git a/src/calibre/gui2/tweak_book/editor/themes.py b/src/calibre/gui2/tweak_book/editor/themes.py index eac7d29a8d..cd04d30a5d 100644 --- a/src/calibre/gui2/tweak_book/editor/themes.py +++ b/src/calibre/gui2/tweak_book/editor/themes.py @@ -60,7 +60,7 @@ SOLARIZED = \ SpecialCharacter bg={base02} Error us=wave uc={red} - SpellError us=wave uc={orange} + SpellError us=spell uc={orange} Tooltip fg=black bg=ffffed DiffDelete bg={base02} fg={red} @@ -101,7 +101,7 @@ THEMES = { Keyword fg={keyword} Special fg=e7f6da Error us=wave uc=red - SpellError us=wave uc=orange + SpellError us=spell uc=orange SpecialCharacter bg={cursor_loc} DiffDelete bg=341414 fg=642424 @@ -148,7 +148,7 @@ THEMES = { Special fg=70a0d0 italic SpecialCharacter bg={cursor_loc} Error us=wave uc=red - SpellError us=wave uc=orange + SpellError us=spell uc=magenta DiffDelete bg=rgb(255,180,200) fg=rgb(200,80,110) DiffInsert bg=rgb(180,255,180) fg=rgb(80,210,80) diff --git a/src/calibre/gui2/tweak_book/editor/widget.py b/src/calibre/gui2/tweak_book/editor/widget.py index 837f88b6ac..4e9cdfd53a 100644 --- a/src/calibre/gui2/tweak_book/editor/widget.py +++ b/src/calibre/gui2/tweak_book/editor/widget.py @@ -14,8 +14,10 @@ from PyQt4.Qt import ( QImage, QColor, QIcon, QPixmap, QToolButton) from calibre.gui2 import error_dialog -from calibre.gui2.tweak_book import actions, current_container, tprefs +from calibre.gui2.tweak_book import actions, current_container, tprefs, dictionaries +from calibre.gui2.tweak_book.editor import SPELL_PROPERTY from calibre.gui2.tweak_book.editor.text import TextEdit +from calibre.utils.icu import utf16_length def create_icon(text, palette=None, sz=32, divider=2): if palette is None: @@ -79,6 +81,7 @@ class Editor(QMainWindow): copy_available_state_changed = pyqtSignal(object) data_changed = pyqtSignal(object) cursor_position_changed = pyqtSignal() + word_ignored = pyqtSignal(object, object) def __init__(self, syntax, parent=None): QMainWindow.__init__(self, parent) @@ -258,6 +261,10 @@ class Editor(QMainWindow): self.modification_state_changed.disconnect() except TypeError: pass # in case this signal was never connected + try: + self.word_ignored.disconnect() + except TypeError: + pass # in case this signal was never connected self.undo_redo_state_changed.disconnect() self.copy_available_state_changed.disconnect() self.cursor_position_changed.disconnect() @@ -344,6 +351,43 @@ class Editor(QMainWindow): def show_context_menu(self, pos): m = QMenu(self) a = m.addAction + c = self.editor.cursorForPosition(pos) + fmt = self.editor.syntax_format_for_cursor(c) + spell = fmt.property(SPELL_PROPERTY).toPyObject() if fmt is not None else None + if spell is not None: + word, locale = spell + orig_pos = c.position() + c.setPosition(orig_pos - utf16_length(word)) + found = False + self.editor.setTextCursor(c) + if self.editor.find_spell_word([word], locale.langcode, center_on_cursor=False): + found = True + fc = self.editor.textCursor() + if fc.position() < c.position(): + self.editor.find_spell_word([word], locale.langcode, center_on_cursor=False) + if found: + suggestions = dictionaries.suggestions(word, locale)[:7] + if suggestions: + for suggestion in suggestions: + ac = m.addAction(suggestion, partial(self.editor.simple_replace, suggestion)) + f = ac.font() + f.setBold(True), ac.setFont(f) + m.addSeparator() + m.addAction(actions['spell-next']) + m.addAction(_('Ignore this word'), partial(self._nuke_word, None, word, locale)) + dics = dictionaries.active_user_dictionaries + if len(dics) > 0: + if len(dics) == 1: + m.addAction(_('Add this word to the dictionary: {0}').format(dics[0].name), partial( + self._nuke_word, dics[0].name, word, locale)) + else: + ac = m.addAction(_('Add this word to the dictionary')) + dmenu = QMenu(m) + ac.setMenu(dmenu) + for dic in dics: + dmenu.addAction(dic.name, partial(self._nuke_word, dic.name, word, locale)) + m.addSeparator() + for x in ('undo', 'redo'): a(actions['editor-%s' % x]) m.addSeparator() @@ -356,6 +400,12 @@ class Editor(QMainWindow): m.addAction(actions['multisplit']) m.exec_(self.editor.mapToGlobal(pos)) + def _nuke_word(self, dic, word, locale): + if dic is None: + dictionaries.ignore_word(word, locale) + else: + dictionaries.add_to_user_dictionary(dic, word, locale) + self.word_ignored.emit(word, locale) def launch_editor(path_to_edit, path_is_raw=False, syntax='html'): from calibre.gui2.tweak_book.main import option_parser diff --git a/src/calibre/gui2/tweak_book/preferences.py b/src/calibre/gui2/tweak_book/preferences.py index 059103258d..0e5bd2c84f 100644 --- a/src/calibre/gui2/tweak_book/preferences.py +++ b/src/calibre/gui2/tweak_book/preferences.py @@ -194,6 +194,13 @@ class EditorSettings(BasicSettings): ' time you open a HTML/CSS/etc. file for editing.')) l.addRow(lw) + lw = self('inline_spell_check') + lw.setText(_('Show misspelled words underlined in the code view')) + lw.setToolTip('

' + _( + 'This will cause spelling errors to be highlighted in the code view' + ' for easy correction as you type.')) + l.addRow(lw) + self.dictionaries = d = QPushButton(_('Manage &spelling dictionaries'), self) d.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) d.clicked.connect(self.manage_dictionaries) diff --git a/src/calibre/gui2/tweak_book/spell.py b/src/calibre/gui2/tweak_book/spell.py index 43939513cf..ddd2169aa0 100644 --- a/src/calibre/gui2/tweak_book/spell.py +++ b/src/calibre/gui2/tweak_book/spell.py @@ -19,7 +19,7 @@ from PyQt4.Qt import ( QComboBox, QListWidget, QListWidgetItem, QInputDialog, QPlainTextEdit, QKeySequence) from calibre.constants import __appname__, plugins -from calibre.ebooks.oeb.polish.spell import replace_word, get_all_words, merge_locations +from calibre.ebooks.oeb.polish.spell import replace_word, get_all_words, merge_locations, get_checkable_file_names from calibre.gui2 import choose_files, error_dialog from calibre.gui2.complete2 import LineEdit from calibre.gui2.languages import LanguagesEdit @@ -585,6 +585,8 @@ class ManageDictionaries(Dialog): # {{{ # Spell Check Dialog {{{ class WordsModel(QAbstractTableModel): + word_ignored = pyqtSignal(object, object) + def __init__(self, parent=None): QAbstractTableModel.__init__(self, parent) self.counts = (0, 0) @@ -705,6 +707,7 @@ class WordsModel(QAbstractTableModel): (dictionaries.unignore_word if ignored else dictionaries.ignore_word)(*w) self.spell_map[w] = dictionaries.recognized(*w) self.update_word(w) + self.word_ignored.emit(*w) def ignore_words(self, rows): words = {self.word_for_row(r) for r in rows} @@ -714,6 +717,7 @@ class WordsModel(QAbstractTableModel): (dictionaries.unignore_word if ignored else dictionaries.ignore_word)(*w) self.spell_map[w] = dictionaries.recognized(*w) self.update_word(w) + self.word_ignored.emit(*w) def add_word(self, row, udname): w = self.word_for_row(row) @@ -721,6 +725,7 @@ class WordsModel(QAbstractTableModel): if dictionaries.add_to_user_dictionary(udname, *w): self.spell_map[w] = dictionaries.recognized(*w) self.update_word(w) + self.word_ignored.emit(*w) def add_words(self, dicname, rows): words = {self.word_for_row(r) for r in rows} @@ -730,6 +735,7 @@ class WordsModel(QAbstractTableModel): dictionaries.remove_from_user_dictionary(dicname, [w]) self.spell_map[w] = dictionaries.recognized(*w) self.update_word(w) + self.word_ignored.emit(*w) def remove_word(self, row): w = self.word_for_row(row) @@ -855,6 +861,7 @@ class SpellCheck(Dialog): find_word = pyqtSignal(object, object) refresh_requested = pyqtSignal() word_replaced = pyqtSignal(object) + word_ignored = pyqtSignal(object, object) def __init__(self, parent=None): self.__current_word = None @@ -922,6 +929,7 @@ class SpellCheck(Dialog): w.setModel(m) m.dataChanged.connect(self.current_word_changed) m.modelReset.connect(self.current_word_changed) + m.word_ignored.connect(self.word_ignored) if state is not None: hh.restoreState(state) # Sort by the restored state, if any @@ -1249,6 +1257,30 @@ def find_next(word, locations, current_editor, current_editor_name, show_editor(file_name) return True return False + +def find_next_error(current_editor, current_editor_name, gui_parent, show_editor, edit_file): + files = get_checkable_file_names(current_container())[0] + if current_editor_name not in files: + current_editor_name = None + else: + idx = files.index(current_editor_name) + before, after = files[:idx], files[idx+1:] + files = [current_editor_name] + after + before + [current_editor_name] + + for file_name in files: + from_cursor = False + if file_name == current_editor_name: + from_cursor = True + current_editor_name = None + ed = editors.get(file_name, None) + if ed is None: + edit_file(file_name) + ed = editors[file_name] + if ed.editor.find_next_spell_error(from_cursor=from_cursor): + show_editor(file_name) + return True + return False + # }}} if __name__ == '__main__': diff --git a/src/calibre/gui2/tweak_book/ui.py b/src/calibre/gui2/tweak_book/ui.py index 9570bbf95c..c43084f54c 100644 --- a/src/calibre/gui2/tweak_book/ui.py +++ b/src/calibre/gui2/tweak_book/ui.py @@ -410,6 +410,8 @@ class Main(MainWindow): self.check_book.next_error, delta=1), 'check-book-next', ('Ctrl+F7'), _('Show next error')) self.action_check_book_previous = reg('back.png', _('&Previous error'), partial( self.check_book.next_error, delta=-1), 'check-book-previous', ('Ctrl+Shift+F7'), _('Show previous error')) + self.action_spell_check_next = reg('forward.png', _('&Next spelling mistake'), + self.boss.next_spell_error, 'spell-next', ('F8'), _('Go to next spelling mistake')) # Miscellaneous actions group = _('Miscellaneous')