forked from onlyskin/Anki-LilyPond
-
Notifications
You must be signed in to change notification settings - Fork 0
/
__init__.py
360 lines (272 loc) · 11.3 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# -*- coding: utf-8 -*-
# Copyright (c) 2012 Andreas Klauer <Andreas.Klauer@metamorpher.de>
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""
LilyPond (GNU Music Typesetter) integration addon for Anki 2.
Code is based on / inspired by libanki's LaTeX integration.
"""
# --- Imports: ---
import cgi
import re
import shutil
from html.entities import entitydefs
from typing import Any
from typing import Dict
from anki.cards import Card
from anki.lang import _
from anki.media import MediaManager
from anki.models import NoteType
from anki.utils import call
from anki.utils import checksum
from anki.utils import stripHTML
from anki.utils import tmpfile
from aqt import gui_hooks
from aqt import mw
from aqt.editor import Editor
from aqt.qt import *
from aqt.utils import getOnlyText
from aqt.utils import showInfo
# --- Globals: ---
# Load configuration options
_config = mw.addonManager.getConfig(__name__)
TEMP_FILE = tmpfile("lilypond", ".ly")
LILYPOND_CMD = [_config['executable'],] + _config['command_line_params'] + ["--o", TEMP_FILE, TEMP_FILE,]
OUTPUT_FILE_EXT = _config["output_file_ext"]
DEFAULT_TEMPLATE = _config['default_template']
USER_FILES_DIR = os.path.join(mw.pm.addonFolder(), __name__, "user_files") # Template directory
# TODO Extract these to config file?
LILYPOND_PATTERN = "%ANKI%" # Substitution targets in templates
LILYPOND_SPLIT = "%%%" # LilyPond code section delimiter
TAG_REGEXP = re.compile( # Match tagged code
r"\[lilypond(=(?P<template>[a-z0-9_-]+))?\](?P<code>.+?)\[/lilypond\]", re.DOTALL | re.IGNORECASE)
FIELD_NAME_REGEXP = re.compile( # Match LilyPond field names
r"^(?P<field>.*)-lilypond(-(?P<template>[a-z0-9_-]+))?$", re.DOTALL | re.IGNORECASE)
TARGET_FIELD_NAME_SUFFIX = "-lilypondimg" # Suffix on LilyPond field destinations
TEMPLATE_NAME_REGEXP = re.compile(r"^[a-z0-9_-]+$", re.DOTALL | re.IGNORECASE) # Template names must match this
IMG_TAG_REGEXP = re.compile("^<img.*>$", re.DOTALL | re.IGNORECASE) # Detects if field already contains rendered img
CARD_EDITOR_PREFIX = "clayout" # Prefix on `kind` parameter of card_will_show hook in card template previewer
loaded_templates = {} # Dict of template name: template code, avoids reading from file repeatedly
lilypondCache = {} # Cache for error-producing code, avoid re-rendering erroneous code
os.environ['PATH'] = f"{os.environ['PATH']}:/usr/local/bin" # TODO Platform independence?
# --- Templates: ---
def tpl_file(name):
"""Build the full filename for template name."""
return os.path.join(USER_FILES_DIR, "%s.ily" % (name,))
def set_template(name, content):
"""Set and save a template."""
loaded_templates[name] = content
f = open(tpl_file(name), 'w')
f.write(content)
def get_template(name: str = DEFAULT_TEMPLATE, code: str = LILYPOND_PATTERN) -> str:
"""
Load template by name and fill it with code.
:param name: Name of template, default is used if passed None
:param code: LilyPond code to insert into template
:return: Templated code
"""
if not name:
name = DEFAULT_TEMPLATE
tpl = None
if name not in loaded_templates:
try:
tpl = open(tpl_file(name)).read()
if tpl and LILYPOND_PATTERN in tpl:
loaded_templates[name] = tpl
finally:
if name not in loaded_templates:
raise IOError("LilyPond Template %s not found or not valid." % (name,))
# Replace one or more occurrences of LILYPOND_PATTERN
codes = code.split(LILYPOND_SPLIT)
r = loaded_templates[name]
for code in codes:
r = r.replace(LILYPOND_PATTERN, code, 1)
return r
# --- GUI: ---
def templatefiles():
"""Produce list of template files."""
return [f for f in os.listdir(USER_FILES_DIR)
if f.endswith(".ly")]
def addtemplate():
"""Dialog to add a new template file."""
name = getOnlyText("Please choose a name for your new LilyPond template:")
if not TEMPLATE_NAME_REGEXP.match(name):
showInfo("Empty template name or invalid characters.")
return
if os.path.exists(tpl_file(name)):
showInfo("A template with that name already exists.")
set_template(name)
mw.addonManager.onEdit(tpl_file(name))
def lilypondMenu():
"""Extend the addon menu with lilypond template entries."""
lilypond_menu = mw.form.menuTools.addMenu('Lilypond')
a = QAction(_("Add template..."), mw)
a.triggered.connect(lambda _, o=mw: addtemplate())
lilypond_menu.addAction(a)
for file in templatefiles():
m = lilypond_menu.addMenu(os.path.splitext(file)[0])
a = QAction(_("Edit..."), mw)
p = os.path.join(USER_FILES_DIR, file)
a.triggered.connect(lambda _, o=mw: mw.addonManager.onEdit(i))
m.addAction(a)
a = QAction(_("Delete..."), mw)
a.triggered.connect(lambda _, o=mw: mw.addonManager.onRem(i))
m.addAction(a)
# --- Functions: ---
def _ly_from_html(ly):
"""Convert entities and fix newlines."""
ly = re.sub(r"<(br|div|p) */?>", "\n", ly)
ly = stripHTML(ly)
ly = ly.replace(" ", " ")
for match in re.compile(r"&([a-zA-Z]+);").finditer(ly):
if match.group(1) in entitydefs:
ly = ly.replace(match.group(), entitydefs[match.group(1)])
return ly
def _build_img(ly, fname):
"""
Build the image file itself and add it to the media dir.
:param ly: LilyPond code
:param fname: Filename for rendered image
:return: None if successful, else error message
"""
lyfile = open(TEMP_FILE, "w")
lyfile.write(ly)
lyfile.close()
log = open(TEMP_FILE + ".log", "w")
if call(LILYPOND_CMD, stdout=log, stderr=log):
return _err_msg("lilypond")
# add to media
try:
shutil.move(TEMP_FILE + OUTPUT_FILE_EXT, os.path.join(mw.col.media.dir(), fname))
except:
return _("Could not move LilyPond image file to media dir. No output?<br>") + _err_msg("lilypond")
def _img_link(template, ly_code) -> str:
"""
Convert LilyPond code to an HTML img tag, rendering image if necessary.
Note that code producing an error will be cached for performance reasons
so Anki may need be resarted after fixing errors in LilyPond configuration, etc.
:param template: Template, uses default if passed None
:param ly_code: LilyPond code
:return: HTML img tag
"""
if not template:
template = DEFAULT_TEMPLATE
# Finalize LilyPond source.
ly_code = get_template(template, ly_code)
filename = f"lilypond-{checksum(ly_code.encode('utf_8'))}{OUTPUT_FILE_EXT}"
# TODO Attach template & substituted code in way that allows recompilation
link = f'<img src="{filename}" alt="{ly_code}">'
# Build image if necessary.
if os.path.exists(filename):
# Image for given code already exists
return link
else:
# Need to render image
if filename in lilypondCache:
# Already tried to render this code & got error
return lilypondCache[filename]
err = _build_img(ly_code, filename)
if err:
# Error rendering, cache filename (i.e. code checksum) to avoid trying to re-render in future
# TODO Account for transient errors (i.e. beyond those in code) that can be solved by re-rendering
lilypondCache[filename] = err
return err
else:
return link
def _err_msg(type):
"""Error message, will be displayed in the card itself."""
msg = (_("Error executing %s.") % type) + "<br>"
try:
log = open(TEMP_FILE + ".log", "r").read()
if log:
msg += """<small><pre style="text-align: left">""" + cgi.escape(log) + "</pre></small>"
except:
msg += _("Have you installed lilypond?")
return msg
def _getfields(notetype: Union[NoteType,Dict[str,Any]]):
'''Get list of field names for given note type'''
return list(field['name'] for field in notetype['flds'])
# --- Hooks: ---
def _munge_string(text: str) -> str:
"""
Replaces tagged LilyPond code with rendered images
:return: Text with tags substituted in-place
"""
for match in TAG_REGEXP.finditer(text):
ly_code = _ly_from_html(match.group(TAG_REGEXP.groupindex['code']))
template_name = match.group(TAG_REGEXP.groupindex['template'])
text = text.replace(
match.group(), _img_link(template_name, ly_code)
)
return text
def munge_card(html: str, card: Card, kind: str):
if kind.startswith(CARD_EDITOR_PREFIX):
# In card editor, may contain invalid but tagged LilyPod code
return html
return _munge_string(html)
gui_hooks.card_will_show.append(munge_card)
def munge_field(txt: str, editor: Editor):
"""Parse -lilypond field/lilypond tags in field before saving"""
if editor.currentField is not None:
fields: list[str] = _getfields(editor.note.model())
field: str = fields[editor.currentField]
if field_match := FIELD_NAME_REGEXP.match(field):
# LilyPond field
template_name = field_match.group(FIELD_NAME_REGEXP.groupindex['template'])
# Check to avoid compiling empty templates
img_link = _img_link(template_name, _ly_from_html(txt)) if txt != "" else txt
if (dest_field := field_match.group(FIELD_NAME_REGEXP.groupindex['field']) + TARGET_FIELD_NAME_SUFFIX)\
in fields:
# Target field exists, populate it
editor.note[dest_field] = img_link
return txt
else:
# Substitute in-place
if IMG_TAG_REGEXP.match(txt):
# Field already contains rendered image
return txt
else:
return img_link
elif field.endswith(TARGET_FIELD_NAME_SUFFIX):
# Field is a destination for rendered images, won't contain code
return txt
else:
# Normal field
# Substitute LilyPond tags
return _munge_string(txt)
else:
# Fallback if can't identify current field
# Substitute LilyPond tags
return _munge_string(txt)
gui_hooks.editor_will_munge_html.append(munge_field)
# Commenting out until I can work out how to replace the
# onEdit and onRem calls in Anki 2.1
# def profileLoaded():
# """Monkey patch the addon manager."""
# lilypondMenu()
#
#
# addHook("profileLoaded", profileLoaded)
anki_check = MediaManager.check
def alert(message):
box = QMessageBox()
box.setText(str(message))
box.exec_()
# def lilypond_check(self, local=None):
# files = []
# for nid, mid, fields in self.col.db.execute("SELECT id, mid, flds FROM notes"):
# model = self.col.models.get(mid)
# note = self.col.getNote(nid)
# data = [None, note.id]
# flist = splitFields(fields)
# fields = {}
# for (name, (idx, conf)) in list(self.col.models.fieldMap(model).items()):
# fields[name] = flist[idx]
# (fields, note_files) = munge_fields(fields, model, data, self.col)
# files = files + note_files
# anki_results = anki_check(self, local)
# files_to_delete = [x for x in anki_results[1] if x not in files]
# return anki_results[0], files_to_delete, anki_results[2]
#
#
# MediaManager.check = lilypond_check
# --- End of file. ---