diff --git a/aqt/editor.py b/aqt/editor.py index d809f50ff..48ea0bc2d 100644 --- a/aqt/editor.py +++ b/aqt/editor.py @@ -7,10 +7,12 @@ import urllib.request, urllib.error, urllib.parse import ctypes import urllib.request, urllib.parse, urllib.error import warnings +import html from anki.lang import _ from aqt.qt import * -from anki.utils import stripHTML, isWin, isMac, namedtmp, json, stripHTMLMedia +from anki.utils import stripHTML, isWin, isMac, namedtmp, json, stripHTMLMedia, \ + checksum import anki.sound from anki.hooks import runHook, runFilter from aqt.sound import getAudio @@ -171,6 +173,11 @@ function onDragOver(elem) { dropTarget = elem; } +function onPaste(elem) { + pycmd("paste"); + window.event.preventDefault(); +} + function caretToEnd() { var r = document.createRange() r.selectNodeContents(currentField); @@ -249,7 +256,7 @@ function setFields(fields, focusTo) { txt += "{0}".format(n); txt += "
as we aren't allowed to change top level elements + var top = $.parseHTML("" + html + "")[0]; + filterNode(top); + var outHtml = top.innerHTML; + // get rid of nbsp + outHtml = outHtml.replace(/ /ig, " "); + //console.log(`input html: ${html}`); + //console.log(`outpt html: ${outHtml}`); + return outHtml; +}; + +var allowedTags = {}; + +for (let tag of [ + "H1", "H2", "H3", "P", "DIV", "BR", "LI", "UL", "OL", + "B", "I", "U", "BLOCKQUOTE", "CODE", "EM", "STRONG", + "PRE", "SUB", "SUP", "TABLE"]) { + allowedTags[tag] = {"attrs": []}; +} + +allowedTags["A"] = {"attrs": ["HREF"]}; +allowedTags["TR"] = {"attrs": ["ROWSPAN"]}; +allowedTags["TD"] = {"attrs": ["COLSPAN", "ROWSPAN"]}; +allowedTags["TH"] = {"attrs": ["COLSPAN", "ROWSPAN"]}; +allowedTags["IMG"] = {"attrs": ["SRC"]}; + +var filterNode = function(node) { + // if it's a text node, nothing to do + if (node.nodeType == 3) { + return; + } + + // descend first, and take a copy of the child nodes as the loop will skip + // elements due to node modifications otherwise + + var nodes = []; + for (let i=0; i": txt = "" - return self._filterHTML(txt, localize=False) + return txt # Setting/unsetting the current note ###################################################################### @@ -870,63 +951,27 @@ to a cloze type first, via Edit>Change Note Type.""")) path = urllib.parse.unquote(url) return self.mw.col.media.writeData(path, filecontents) - # HTML filtering + # Paste/drag&drop ###################################################################### - def _filterHTML(self, html, localize=False): + removeTags = ["script", "iframe", "object", "style"] + + def _pastePreFilter(self, html): with warnings.catch_warnings() as w: warnings.simplefilter('ignore', UserWarning) doc = BeautifulSoup(html, "html.parser") - # remove implicit regular font style from outermost element - if doc.span: - try: - attrs = doc.span['style'].split(";") - except (KeyError, TypeError): - attrs = [] - if attrs: - new = [] - for attr in attrs: - sattr = attr.strip() - if sattr and sattr not in ("font-style: normal", "font-weight: normal"): - new.append(sattr) - doc.span['style'] = ";".join(new) - # filter out implicit formatting from webkit - for tag in doc("span", "Apple-style-span"): - preserve = "" - for item in tag['style'].split(";"): - try: - k, v = item.split(":") - except ValueError: - continue - if k.strip() == "color" and not v.strip() == "rgb(0, 0, 0)": - preserve += "color:%s;" % v - if k.strip() in ("font-weight", "font-style"): - preserve += item + ";" - if preserve: - # preserve colour attribute, delete implicit class - tag['style'] = preserve - del tag['class'] - else: - # strip completely - tag.replaceWithChildren() - for tag in doc("font", "Apple-style-span"): - # strip all but colour attr from implicit font tags - if 'color' in dict(tag.attrs): - for attr in tag.attrs: - if attr != "color": - del tag[attr] - # and apple class - del tag['class'] - else: - # remove completely - tag.replaceWithChildren() - # now images + + for tag in self.removeTags: + for node in doc(tag): + node.decompose() + + # convert p tags to divs + for node in doc("p"): + node.name = "div" + for tag in doc("img"): - # turn file:/// links into relative ones try: - if tag['src'].lower().startswith("file://"): - tag['src'] = os.path.basename(tag['src']) - if localize and self.isURL(tag['src']): + if self.isURL(tag['src']): # convert remote image links to local ones fname = self.urlToFile(tag['src']) if fname: @@ -935,17 +980,23 @@ to a cloze type first, via Edit>Change Note Type.""")) # for some bizarre reason, mnemosyne removes src elements # from missing media pass - # strip all other attributes, including implicit max-width - for attr, val in tag.attrs.items(): - if attr != "src": - del tag[attr] - # strip superfluous elements - for elem in "html", "head", "body", "meta": - for tag in doc(elem): - tag.replaceWithChildren() + html = str(doc) return html + def doPaste(self, html, internal): + if not internal: + html = self._pastePreFilter(html) + self.web.eval("pasteHTML(%s);" % json.dumps(html)) + + def doDrop(self, html, internal): + self.web.evalWithCallback("dropTarget.focus();", + lambda _: self.doPaste(html, internal)) + self.web.setFocus() + + def onPaste(self): + self.web.onPaste() + # Advanced menu ###################################################################### @@ -992,14 +1043,12 @@ to a cloze type first, via Edit>Change Note Type.""")) record=onRecSound, more=onAdvanced, dupes=showDupes, + paste=onPaste, ) # Pasting, drag & drop, and keyboard layouts ###################################################################### -# fixme: drag & drop -# fixme: middle click to paste - class EditorWebView(AnkiWebView): def __init__(self, parent, editor): @@ -1017,170 +1066,100 @@ class EditorWebView(AnkiWebView): self._flagAnkiText() def onPaste(self): - mime = self.mungeClip() - self.triggerPageAction(QWebEnginePage.Paste) - self.restoreClip() + mime = self.editor.mw.app.clipboard().mimeData(mode=QClipboard.Clipboard) + html, internal = self._processMime(mime) + if not html: + return + self.editor.doPaste(html, internal) - # def mouseReleaseEvent(self, evt): - # if not isMac and not isWin and evt.button() == Qt.MidButton: - # # middle click on x11; munge the clipboard before standard - # # handling - # mime = self.mungeClip(mode=QClipboard.Selection) - # AnkiWebView.mouseReleaseEvent(self, evt) - # self.restoreClip(mode=QClipboard.Selection) - # else: - # AnkiWebView.mouseReleaseEvent(self, evt) - # - # def dropEvent(self, evt): - # oldmime = evt.mimeData() - # # coming from this program? - # if evt.source(): - # if oldmime.hasHtml(): - # mime = QMimeData() - # mime.setHtml(self.editor._filterHTML(oldmime.html())) - # else: - # # old qt on linux won't give us html when dragging an image; - # # in that case just do the default action (which is to ignore - # # the drag) - # return AnkiWebView.dropEvent(self, evt) - # else: - # mime = self._processMime(oldmime) - # # create a new event with the new mime data and run it - # new = QDropEvent(evt.pos(), evt.possibleActions(), mime, - # evt.mouseButtons(), evt.keyboardModifiers()) - # evt.accept() - # AnkiWebView.dropEvent(self, new) - # # tell the drop target to take focus so the drop contents are saved - # self.eval("dropTarget.focus();") - # self.setFocus() + def dropEvent(self, evt): + mime = evt.mimeData() - def mungeClip(self, mode=QClipboard.Clipboard): - clip = self.editor.mw.app.clipboard() - mime = clip.mimeData(mode=mode) - self.saveClip(mode=mode) - mime = self._processMime(mime) - clip.setMimeData(mime, mode=mode) - return mime - - def restoreClip(self, mode=QClipboard.Clipboard): - clip = self.editor.mw.app.clipboard() - clip.setMimeData(self.savedClip, mode=mode) - - def saveClip(self, mode): - # we don't own the clipboard object, so we need to copy it or we'll crash - mime = self.editor.mw.app.clipboard().mimeData(mode=mode) - n = QMimeData() - if mime.hasText(): - n.setText(mime.text()) - if mime.hasHtml(): - n.setHtml(mime.html()) - if mime.hasUrls(): - n.setUrls(mime.urls()) - if mime.hasImage(): - n.setImageData(mime.imageData()) - self.savedClip = n - - def _processMime(self, mime): - # print "html=%s image=%s urls=%s txt=%s" % ( - # mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText()) - # print "html", mime.html() - # print "urls", mime.urls() - # print "text", mime.text() - if mime.hasHtml(): - return self._processHtml(mime) - elif mime.hasUrls(): - return self._processUrls(mime) - elif mime.hasText(): - return self._processText(mime) - elif mime.hasImage(): - return self._processImage(mime) + if evt.source() and mime.hasHtml(): + # don't filter html from other fields + html, internal = mime.html(), True else: - # nothing - return QMimeData() + html, internal = self._processMime(mime) + + if not html: + return + + self.editor.doDrop(html, internal) + + # returns (html, isInternal) + def _processMime(self, mime): + print("html=%s image=%s urls=%s txt=%s" % ( + mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText())) + print("html", mime.html()) + print("urls", mime.urls()) + print("text", mime.text()) + + # try various content types in turn + html, internal = self._processHtml(mime) + if html: + return html, internal + for fn in (self._processUrls, self._processImage, self._processText): + html = fn(mime) + if html: + return html, False + return "", False - # when user is dragging a file from a file manager on any platform, the - # url type should be set, and it is not URL-encoded. on a mac no text type - # is returned, and on windows the text type is not returned in cases like - # "foo's bar.jpg" def _processUrls(self, mime): + if not mime.hasUrls(): + return + url = mime.urls()[0].toString() # chrome likes to give us the URL twice with a \n url = url.splitlines()[0] - newmime = QMimeData() - link = self.editor.urlToLink(url) - if link: - newmime.setHtml(link) - elif mime.hasImage(): - # if we couldn't convert the url to a link and there's an - # image on the clipboard (such as copy&paste from - # google images in safari), use that instead - return self._processImage(mime) - else: - newmime.setText(url) - return newmime + return self.editor.urlToLink(url) - # if the user has used 'copy link location' in the browser, the clipboard - # will contain the URL as text, and no URLs or HTML. the URL will already - # be URL-encoded, and shouldn't be a file:// url unless they're browsing - # locally, which we don't support def _processText(self, mime): - txt = str(mime.text()) - html = None + if not mime.hasText(): + return + + txt = mime.text() + # if the user is pasting an image or sound link, convert it to local if self.editor.isURL(txt): txt = txt.split("\r\n")[0] - html = self.editor.urlToLink(txt) - new = QMimeData() - if html: - new.setHtml(html) - else: - new.setText(txt) - return new + return self.editor.urlToLink(txt) + + # normal text; convert it to HTML + return html.escape(txt) def _processHtml(self, mime): + if not mime.hasHtml(): + return None, False html = mime.html() - newMime = QMimeData() - if self.strip and not html.startswith(""): - # special case for google images: if after stripping there's no text - # and there are image links, we'll paste those as html instead - if not stripHTML(html).strip(): - newHtml = "" - mid = self.editor.note.mid - for url in self.editor.mw.col.media.filesInStr( - mid, html, includeRemote=True): - newHtml += self.editor.urlToLink(url) - if not newHtml and mime.hasImage(): - return self._processImage(mime) - newMime.setHtml(newHtml) - else: - # use .text() if available so newlines are preserved; otherwise strip - if mime.hasText(): - return self._processText(mime) - else: - newMime.setText(stripHTML(mime.text())) - else: - if html.startswith(""): - html = html[11:] - # no html stripping - html = self.editor._filterHTML(html, localize=True) - newMime.setHtml(html) - return newMime + + # no filtering required for internal pastes + if html.startswith(""): + return html[11:], True + + return html, False def _processImage(self, mime): im = QImage(mime.imageData()) - uname = namedtmp("paste-%d" % im.cacheKey()) + uname = namedtmp("paste") if self.editor.mw.pm.profile.get("pastePNG", False): ext = ".png" im.save(uname+ext, None, 50) else: ext = ".jpg" im.save(uname+ext, None, 80) + # invalid image? - if not os.path.exists(uname+ext): - return QMimeData() - mime = QMimeData() - mime.setHtml(self.editor._addMedia(uname+ext)) - return mime + path = uname+ext + if not os.path.exists(path): + return + + # hash and rename + csum = checksum(open(path, "rb").read()) + newpath = "{}-{}{}".format(uname, csum, ext) + os.rename(path, newpath) + + # add to media and return resulting html link + return self.editor._addMedia(newpath) def _flagAnkiText(self): # add a comment in the clipboard html so we can tell text is copied