在Anki中进行复习时,每次只能打开一条笔记。如果积累了很多笔记,有时候会有将它们集中输出成一个pdf进行阅读的想法。Anki插件Export deck to html(安装ID:1897277426)就有这个功能。但是,这个插件目前存在以下问题:
1、Anki升级为版本 24.06.3 (d678e393)后(也许更早的版本就这样,我没试过),插件无法正常运行;
2、插件转pdf的效果不是很好,但转html的效果不错。考虑到html转pdf非常容易(word即可完成,多数浏览器在插件支持下或无需插件也能完成),所以插件的转pdf功能比较鸡肋;
3、笔记中的img标签,在转换为html后,除了“src”属性得以保留,其余的属性会全部丢失。
4、输出的html在每一条笔记前添加了没有用处的前缀“>y”
鉴于上述问题,所以对该插件的主文件ExportDeckToHtml.py进行了修改。具体修改的内容包括:
1、将不在兼容新版Anki的几行代码进行修改和删除,其中包括
1)dialog.exec_()修改为dialog.exec()
2)options = QFileDialog.DontUseNativeDialog删除
3)path = QFileDialog.getSaveFileName( self, "Save File", directory, "All Files (*)", options=options) 修改为path = QFileDialog.getSaveFileName( self, "Save File", directory, "All Files (*)")
2、修改_setup_ui函数,取消界面上的保存为pdf等元素。
3、修改_export_to_html函数,在处理卡片的html中的img标签时,只将src属性中的路径修改为绝对路径,而src属性之外的其他属性保持不变。
4、修改每条笔记的html,增加笔记序号信息,删掉无用前缀。
修改后的ExportDeckToHtml.py文件内容如下:
from aqt import mw, utils
from aqt.qt import *
from os.path import expanduser, join
from pickle import load, dumpimport os
import re
import unicodedata
from .pdfkit import from_stringdelimiter = "####"ascending = "Ascending"
descending = "Descending"
config_file = "export_decks_to_html_config.cfg"class AddonDialog(QDialog):"""Main Options dialog"""def __init__(self):global config_fileQDialog.__init__(self, parent=mw)self.path = Noneself.deck = Noneself.fields = {}self.card_orders = [ascending, descending]self.order_fn = Noneself.advance_mode = Falseif os.path.exists(config_file):try:self.config = load(open(config_file, 'rb'))except:self.config = {}else:self.config = {}self._setup_ui()def _handle_button(self):dialog = OpenFileDialog()self.path = dialog.filenameif self.path is not None:utils.showInfo("Choose file successful.")def _handle_load_template(self):dialog = OpenFileDialog()self.advance_mode = Falseself.template_path = dialog.filenameif self.template_path is not None and len(self.template_path) > 0:utils.showInfo("Choose file successful.")self.template_label.setText(self.template_path)def _setup_ui(self):"""Set up widgets and layouts"""layout = QGridLayout()layout.setSpacing(10)deck_label = QLabel("Choose deck")# deck nameself.deck_selection = QComboBox()deck_names = sorted(mw.col.decks.allNames())current_deck = mw.col.decks.current()['name']deck_names.insert(0, current_deck)for i in range(len(deck_names)):if deck_names[i] == 'Default':deck_names.pop(i)breakself.deck_selection.addItems(deck_names)self.deck_selection.currentIndexChanged.connect(self._select_deck)layout.addWidget(deck_label, 1, 0, 1, 1)layout.addWidget(self.deck_selection, 1, 1, 1, 2)export_dir = self.config.get('export_dir', expanduser("~/Desktop"))self.export_dir = QLineEdit(export_dir)field_label = QLabel('Sort')self.field_selection = QComboBox()fields = self._select_fields(self.deck_selection.currentText())if self.deck_selection.currentText() in self.config:currentField = self.config[self.deck_selection.currentText()].get('field_selection', '')if len(currentField) > 0:if currentField in fields:fields.remove(currentField)fields.insert(0, currentField)self.field_selection.addItems(fields)layout.addWidget(field_label, 2, 0, 1, 1)layout.addWidget(self.field_selection, 2, 1, 1, 2)template_path = ''if self.deck_selection.currentText() in self.config:template_path = self.config[self.deck_selection.currentText()].get('template_path', '')self.template_label = QLabel(template_path)# orderorder_label = QLabel('Order')self.order_selection = QComboBox()orders = self.card_orders[:]if self.deck_selection.currentText() in self.config:currentOrder = self.config[self.deck_selection.currentText()].get("order_selection", '')if len(currentOrder) > 0:orders.remove(currentOrder)orders.insert(0, currentOrder)self.order_selection.addItems(orders)self.order_selection.currentIndexChanged.connect(self._handle_order_card)layout.addWidget(order_label, 3, 0, 1, 1)layout.addWidget(self.order_selection, 3, 1, 1, 2)self.load_template_btn = QPushButton('Load template')self.load_template_btn.clicked.connect(self._handle_load_template)layout.addWidget(self.load_template_btn, 4, 0, 1, 1)layout.addWidget(self.template_label, 4, 1, 1, 2)self.to_pdf = Falselayout.addWidget(self.export_dir, 5, 1, 1, 2)export_dir_label = QLabel("Export directory")layout.addWidget(export_dir_label, 5, 0, 1, 1)# Main button boxok_btn = QPushButton("Export")save_btn = QPushButton("Save")cancel_btn = QPushButton("Cancel")button_box = QHBoxLayout()ok_btn.clicked.connect(self._on_accept)save_btn.clicked.connect(self._on_save)cancel_btn.clicked.connect(self._on_reject)button_box.addWidget(ok_btn)button_box.addWidget(save_btn)button_box.addWidget(cancel_btn)# Main layoutmain_layout = QVBoxLayout()main_layout.addLayout(layout)main_layout.addLayout(button_box)self.setLayout(main_layout)self.setMinimumWidth(360)self.setWindowTitle('Export deck to html')def _reset_advance_mode(self):self.advance_mode = Falseself.csv_file_label.setText('')def _to_pdf(self):self.to_pdf = not self.to_pdfdef _handle_adv_mode(self):dialog = OpenFileDialog("csv")self.path = dialog.filenameif self.path is not None and len(self.path) > 0:utils.showInfo("Choose file successful.")self.advance_mode = Trueself.csv_file_label.setText(self.path)def _select_deck(self):current_deck = self.deck_selection.currentText()fields = self._select_fields(current_deck)if self.deck_selection.currentText() in self.config:currentField = self.config[current_deck].get('field_selection', '')if len(currentField) > 0:fields.remove(currentField)fields.insert(0, currentField)self.field_selection.clear()self.field_selection.addItems(fields)orders = self.card_orders[:]if current_deck in self.config:currentOrder = self.config[current_deck].get("order_selection", '')if len(currentOrder) > 0:orders.remove(currentOrder)orders.insert(0, currentOrder)self.order_selection.clear()self.order_selection.addItems(orders)template_path = ''if current_deck in self.config:template_path = self.config[current_deck].get("template_path", '')self.template_label.setText(template_path)def _on_save(self):global config_filecurrent_deck = self.deck_selection.currentText()self.config[current_deck] = {}self.config[current_deck]['template_path'] = self.template_label.text()self.config[current_deck]["field_selection"] = self.field_selection.currentText()self.config[current_deck]["order_selection"] = self.order_selection.currentText()self.config[current_deck]["to_pdf"] = self.to_pdfself.config["export_dir"] = self.export_dir.text()dump(self.config, open(config_file, 'wb'))utils.showInfo("Config saved")def _convert_to_multiple_choices(self, value):choices = value.split("|")letters = "ABCDEFGHIKLMNOP"value = "<div>"for letter, choice in zip(letters, choices):value += '<div>' + \"<span><strong>(" + letter + ") </strong></span>" + \choice.strip() + '</div>'return value + "</div>"def _select_fields(self, deck):query = 'deck:"{}"'.format(deck)try:card_id = mw.col.findCards(query=query)[0]except:utils.showInfo("This deck has no cards.")return []card = mw.col.getCard(card_id)fields = card.note().keys()return ["Due", ] + fieldsdef _handle_order_card(self):self.order_fn = self._order_card(self.order_selection.currentText())def _order_card(self, order_by):def f(field):def g(card):try:if field == 'Due':return card.duereturn card.note()[field]except KeyError:return ''return gdef ascending_fn(cards, field):return sorted(cards, key=f(field))def descending_fn(cards, field):return sorted(cards, key=f(field), reverse=True)if order_by == ascending:return ascending_fnreturn descending_fndef _get_all_cards(self, deck_name, field, order_fn):deck_name = deck_name.replace('"', '')deck_name = unicodedata.normalize('NFC', deck_name)deck = mw.col.decks.byName(deck_name)if deck == None:returndecks = [deck_name, ]if len(mw.col.decks.children(deck['id'])) != 0:decks = [name for (name, _) in mw.col.decks.children(deck['id'])]decks = sorted(decks)all_cards = []for deck in decks:query = 'deck:"{}"'.format(deck)cids = mw.col.findCards(query=query)cards = []for cid in cids:card = mw.col.getCard(cid)cards.append(card)all_cards.extend(cards)if order_fn is not None:return order_fn(all_cards, field)return all_cardsdef _export_to_html(self, output_path, deck_name, sort_by, order, template_path, export_to_pdf=True):# html_path = self.template_label.text()if template_path is None or len(template_path) == 0:return Falseorder_fn = self._order_card(order)cards = self._get_all_cards(deck_name, sort_by, order_fn)if cards is None or len(cards) == 0:return Falsehtml_template = ''with open(template_path, 'r', encoding='utf-8') as f:html_template += f.read()header, body, has_table = self._separate_header_and_body(html_template)collection_path = mw.col.media.dir()path = output_pathtry:html = ""template = bodyfields = re.findall("\{\{[^\}]*\}\}", template)dedup = set()for i, card in enumerate(cards):card_html = templatecard_html = card_html.replace("{{id}}", str(i + 1))key = ""for field in fields:if field == "{{id}}":continuetry:value = card.note()[field[2:-2]]key += valueexcept KeyError:value = '## field ' + field + ' not found ##'card_html = card_html.replace(field, value)# 将html中的相对路径全部替换为绝对路径pattern = re.compile(r'<img.*?src="(.*?)".*?>', re.I | re.M)for match in re.finditer(pattern, card_html):relative_path = match.group(1)absolute_path = f'{collection_path}\\{relative_path}'card_html = card_html.replace(relative_path, absolute_path)if key not in dedup:html += '<span class="red">第' + str(i + 1) + '条:</span>' + card_html[2:]dedup.add(key)if not has_table:html = header + "\n<body>" + html + "</body>"else:html = header + "\n<body>\n\t<table>" + html + "\t</table>\n</body>"if not export_to_pdf:with open(path, "w", encoding="utf8") as f:f.write(html)else:options = {# 'header-left': '[webpage]',# 'header-right': '[page]/[toPage]',# 'header-line': '',# 'header-font-size': 10'margin-bottom': 15,'margin-left': 10,'margin-right': 10,'margin-top': 15,'footer-center': '[page]','footer-font-size': 8,'footer-spacing': 5,}from_string(html, path, options)except IOError as e:return Falsereturn Truedef _on_accept(self):if not self.advance_mode:dialog = SaveFileDialog(self.deck_selection.currentText(), self.export_dir.text(), self.to_pdf)file_path = dialog.filenameif file_path == None:returnif type(file_path) is tuple:file_path = file_path[0]template_path = self.template_label.text()if template_path is None or len(template_path) == 0:utils.showInfo("Cannot find template")returncan_export = self._export_to_html(join(self.export_dir.text(), file_path),self.deck_selection.currentText(),self.field_selection.currentText(),self.order_selection.currentText(),template_path,self.to_pdf)if not can_export:utils.showInfo("Cannot export")else:utils.showInfo("Exported successfully")else:with open(self.path, "r", encoding="utf-8") as f:i = 0non_exist_decks = []non_exist_files = []for line in f:if i == 0:i += 1continuedeck_name, output_dir, output_name, sort_by, order, template_path, to_pdf = \line.split(',')[:7]if output_name is None and len(output_name) == 0:output_name = deck_nameif not os.path.isfile(template_path):non_exist_files.append(template_path)continueto_pdf = True if standardize(to_pdf).lower() == 'true' else Falsecan_export = self._export_to_html(join(standardize(output_dir),standardize(output_name)),standardize(deck_name),standardize(sort_by),standardize(order),standardize(template_path),to_pdf)if not can_export:non_exist_decks.append(deck_name)if len(non_exist_decks) > 0:utils.showInfo("Non existing decks\n" +'\n'.join(non_exist_decks))returnif len(non_exist_files) > 0:utils.showInfo("Non existing files\n" +'\n'.join(non_exist_files))returnutils.showInfo("Exported successfully")def _on_reject(self):self.close()def _separate_header_and_body(self, hl):last_header = hl.find("</head>")last_header += len("</head>")body = hl[last_header:]first = body.find("<table>")last = body.rfind("</table>")if first == -1 or last == -1:first = body.find("<table>") + len("<body>")last = body.find("</body>")has_table = Falseelse:first = first + len("<table>")has_table = Truereturn hl[:last_header][:], body[first:last], has_tableclass SaveFileDialog(QDialog):def __init__(self, filename, export_dir=expanduser("~/Desktop/"), to_pdf=False):QDialog.__init__(self, mw)self.title = 'Save File'self.left = 10self.top = 10self.width = 640self.height = 480self.filename = Noneself.default_filename = filenameself.to_pdf = to_pdfself.export_dir = export_dirself._init_ui()def _init_ui(self):self.setWindowTitle(self.title)self.setGeometry(self.left, self.top, self.width, self.height)self.filename = self._get_file()def _get_file(self):# options = QFileDialog.Options()# 升级后QFileDialog不存在DontUseNativeDialog属性# options = QFileDialog.DontUseNativeDialogdefault_filename = self.default_filename.replace('::', '_')if not self.to_pdf:directory = join(self.export_dir, default_filename + ".html")else:directory = join(self.export_dir, default_filename + ".pdf")try:path = QFileDialog.getSaveFileName(# 取消options参数# self, "Save File", directory, "All Files (*)", options=options)self, "Save File", directory, "All Files (*)")if path:return pathelse:utils.showInfo("Cannot open this file.")except:utils.showInfo("Cannot open this file.")return Noneclass OpenFileDialog(QDialog):def __init__(self, file_type="html"):QDialog.__init__(self, mw)self.title = 'Open file'self.left = 10self.top = 10self.width = 640self.height = 480self.filename = Noneself.file_type = file_typeself._init_ui()def _init_ui(self):self.setWindowTitle(self.title)self.setGeometry(self.left, self.top, self.width, self.height)self.filename = self._get_file()# self.exec_()def _get_file(self):# options = QFileDialog.Options()# 升级后QFileDialog不存在DontUseNativeDialog属性# options = QFileDialog.DontUseNativeDialogdirectory = expanduser("~/Desktop")try:if self.file_type == "html":path = QFileDialog.getOpenFileName(# 取消options参数# self, "Save File", directory, "All Files (*)", options=options)self, "Save File", directory, "All Files (*)")elif self.file_type == "csv":path = QFileDialog.getOpenFileName(# 取消options参数# self, "Save File", directory, "All Files (*)", options=options)self, "Save File", directory, "All Files (*)")if path and path[0]:return path[0]else:utils.showInfo("Cannot open this file.")except:utils.showInfo("Cannot open this file.")return Nonedef display_dialog():dialog = AddonDialog()dialog.exec()# 原来方法名exec_错误,多了下划线# dialog.exec_()def standardize(word):return word.strip()action = QAction("Export deck to html", mw)
action.setShortcut("Ctrl+M")
action.triggered.connect(display_dialog)
mw.form.menuTools.addAction(action)
只需安装该插件,然后打开插件文件夹,编辑ExportDeckToHtml.py文件,将其内容全部替换为以上代码即可。在使用此插件时,需要提前准备一个html模板。我用于导出基于对兼容各操作系统的Anki选择题模板的更新——提供更方便的笔记修改功能-CSDN博客一文中的模板所编写的笔记牌组的html模板如下,可供参考:
<!DOCTYPE html>
<html>
<head><style>body{font-size:1.2em;width:19.7cm;}table {border-collapse: collapse;
}table tr:nth-child(2n+1) {background-color: #eee;
}td {padding: 5px;text-align: center;border: 2px solid green;vertical-align: middle;
}td.left {text-align: left;
}td.red {border-right: solid thick red;
}hr {border: none;height: 5px;background-color: blue;
}div {margin: 5px auto
}a,
a:visited,
a:hover,
a:link,
a:active {color: #f90;font-weight: bold;font-family:Cambria-modify,'干就完事了简','微软雅黑';
}.pink{font-family:'黑体';font-weight: bold;font-size: 1.2em;
}u,
.red {color: #f00;font-weight: bold;text-decoration: none;font-family:Cambria-modify,'干就完事了简','微软雅黑';
}.green,
i {font-weight: bold;font-style: normal;color: #3bb;font-family:Cambria-modify,'Aa奇思胖丫儿','微软雅黑';
}.blue,
b {font-weight: bold;font-style: normal;color: #39e;font-family:Cambria-modify,'微软雅黑';
}img{display:block;object-fit:scale-down;
}</style>
</head>
<body>
<div><span class='pink'>【题干】:</span>{{问题}}</div>
<span class='pink'>【选项】:</span>
<div>{{选项}}</div>
<div><span class='pink'>【答案】:</span>{{答案}}</div>
<span class='pink'>【解析】:</span>
<div>{{解析}}</div>
<hr>
</body>
</html>
基于以上模板输出html的操作过程如下:
导出的html效果如下:
顺便说一句,在试用了十数个Anki插件后,我只保留了两个:Edit field during review和Export deck to html。如果有其他便于Anki使用的插件,欢迎留言推荐,如有改造相关插件的想法,也欢迎留言,我可能会试着帮你实现。