在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, dump
import os
import re
import unicodedata
from .pdfkit import from_string
delimiter = "####"
ascending = "Ascending"
descending = "Descending"
config_file = "export_decks_to_html_config.cfg"
class AddonDialog(QDialog):
"""Main Options dialog"""
def __init__(self):
global config_file
QDialog.__init__(self, parent=mw)
self.path = None
self.deck = None
self.fields = {}
self.card_orders = [ascending, descending]
self.order_fn = None
self.advance_mode = False
if 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.filename
if self.path is not None:
utils.showInfo("Choose file successful.")
def _handle_load_template(self):
dialog = OpenFileDialog()
self.advance_mode = False
self.template_path = dialog.filename
if 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 name
self.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)
break
self.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)
# order
order_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 = False
layout.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 box
ok_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 layout
main_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 = False
self.csv_file_label.setText('')
def _to_pdf(self):
self.to_pdf = not self.to_pdf
def _handle_adv_mode(self):
dialog = OpenFileDialog("csv")
self.path = dialog.filename
if self.path is not None and len(self.path) > 0:
utils.showInfo("Choose file successful.")
self.advance_mode = True
self.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_file
current_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_pdf
self.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", ] + fields
def _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.due
return card.note()[field]
except KeyError:
return ''
return g
def 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_fn
return descending_fn
def _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:
return
decks = [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_cards
def _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 False
order_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 False
html_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_path
try:
html = ""
template = body
fields = re.findall("\{\{[^\}]*\}\}", template)
dedup = set()
for i, card in enumerate(cards):
card_html = template
card_html = card_html.replace("{{id}}", str(i + 1))
key = ""
for field in fields:
if field == "{{id}}":
continue
try:
value = card.note()[field[2:-2]]
key += value
except 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 False
return True
def _on_accept(self):
if not self.advance_mode:
dialog = SaveFileDialog(
self.deck_selection.currentText(), self.export_dir.text(), self.to_pdf)
file_path = dialog.filename
if file_path == None:
return
if 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")
return
can_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 = 0
non_exist_decks = []
non_exist_files = []
for line in f:
if i == 0:
i += 1
continue
deck_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_name
if not os.path.isfile(template_path):
non_exist_files.append(template_path)
continue
to_pdf = True if standardize(
to_pdf).lower() == 'true' else False
can_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))
return
if len(non_exist_files) > 0:
utils.showInfo("Non existing files\n" +
'\n'.join(non_exist_files))
return
utils.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 = False
else:
first = first + len("<table>")
has_table = True
return hl[:last_header][:], body[first:last], has_table
class SaveFileDialog(QDialog):
def __init__(self, filename, export_dir=expanduser("~/Desktop/"), to_pdf=False):
QDialog.__init__(self, mw)
self.title = 'Save File'
self.left = 10
self.top = 10
self.width = 640
self.height = 480
self.filename = None
self.default_filename = filename
self.to_pdf = to_pdf
self.export_dir = export_dir
self._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.DontUseNativeDialog
default_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 path
else:
utils.showInfo("Cannot open this file.")
except:
utils.showInfo("Cannot open this file.")
return None
class OpenFileDialog(QDialog):
def __init__(self, file_type="html"):
QDialog.__init__(self, mw)
self.title = 'Open file'
self.left = 10
self.top = 10
self.width = 640
self.height = 480
self.filename = None
self.file_type = file_type
self._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.DontUseNativeDialog
directory = 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 None
def 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使用的插件,欢迎留言推荐,如有改造相关插件的想法,也欢迎留言,我可能会试着帮你实现。