Anki/pylib/anki/latex.py

180 lines
5 KiB
Python

# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import html
import os
import re
from dataclasses import dataclass
from typing import Any, List, Optional, Tuple
import anki
from anki import hooks
from anki.models import NoteType
from anki.rsbackend import TR, pb
from anki.template import TemplateRenderContext, TemplateRenderOutput
from anki.utils import call, isMac, namedtmp, tmpdir
pngCommands = [
["latex", "-interaction=nonstopmode", "tmp.tex"],
["dvipng", "-D", "200", "-T", "tight", "tmp.dvi", "-o", "tmp.png"],
]
svgCommands = [
["latex", "-interaction=nonstopmode", "tmp.tex"],
["dvisvgm", "--no-fonts", "--exact", "-Z", "2", "tmp.dvi", "-o", "tmp.svg"],
]
build = True # if off, use existing media but don't create new
# add standard tex install location to osx
if isMac:
os.environ["PATH"] += ":/usr/texbin:/Library/TeX/texbin"
@dataclass
class ExtractedLatex:
filename: str
latex_body: str
@dataclass
class ExtractedLatexOutput:
html: str
latex: List[ExtractedLatex]
@staticmethod
def from_proto(proto: pb.ExtractLatexOut) -> ExtractedLatexOutput:
return ExtractedLatexOutput(
html=proto.text,
latex=[
ExtractedLatex(filename=l.filename, latex_body=l.latex_body)
for l in proto.latex
],
)
def on_card_did_render(
output: TemplateRenderOutput, ctx: TemplateRenderContext
) -> None:
output.question_text = render_latex(
output.question_text, ctx.note_type(), ctx.col()
)
output.answer_text = render_latex(output.answer_text, ctx.note_type(), ctx.col())
def render_latex(html: str, model: NoteType, col: anki.collection.Collection) -> str:
"Convert embedded latex tags in text to image links."
html, err = render_latex_returning_errors(html, model, col)
if err:
html += "\n".join(err)
return html
def render_latex_returning_errors(
html: str,
model: NoteType,
col: anki.collection.Collection,
expand_clozes: bool = False,
) -> Tuple[str, List[str]]:
"""Returns (text, errors).
errors will be non-empty if LaTeX failed to render."""
svg = model.get("latexsvg", False)
header = model["latexPre"]
footer = model["latexPost"]
proto = col.backend.extract_latex(text=html, svg=svg, expand_clozes=expand_clozes)
out = ExtractedLatexOutput.from_proto(proto)
errors = []
html = out.html
for latex in out.latex:
# don't need to render?
if not build or col.media.have(latex.filename):
continue
err = _save_latex_image(col, latex, header, footer, svg)
if err is not None:
errors.append(err)
return html, errors
def _save_latex_image(
col: anki.collection.Collection,
extracted: ExtractedLatex,
header: str,
footer: str,
svg: bool,
) -> Optional[str]:
# add header/footer
latex = header + "\n" + extracted.latex_body + "\n" + footer
# it's only really secure if run in a jail, but these are the most common
tmplatex = latex.replace("\\includegraphics", "")
for bad in (
"\\write18",
"\\readline",
"\\input",
"\\include",
"\\catcode",
"\\openout",
"\\write",
"\\loop",
"\\def",
"\\shipout",
):
# don't mind if the sequence is only part of a command
bad_re = "\\" + bad + "[^a-zA-Z]"
if re.search(bad_re, tmplatex):
return col.tr(TR.MEDIA_FOR_SECURITY_REASONS_IS_NOT, val=bad)
# commands to use
if svg:
latexCmds = svgCommands
ext = "svg"
else:
latexCmds = pngCommands
ext = "png"
# write into a temp file
log = open(namedtmp("latex_log.txt"), "w")
texpath = namedtmp("tmp.tex")
texfile = open(texpath, "w", encoding="utf8")
texfile.write(latex)
texfile.close()
oldcwd = os.getcwd()
png_or_svg = namedtmp("tmp.%s" % ext)
try:
# generate png/svg
os.chdir(tmpdir())
for latexCmd in latexCmds:
if call(latexCmd, stdout=log, stderr=log):
return _errMsg(col, latexCmd[0], texpath)
# add to media
with open(png_or_svg, "rb") as file:
data = file.read()
col.media.write_data(extracted.filename, data)
os.unlink(png_or_svg)
return None
finally:
os.chdir(oldcwd)
log.close()
def _errMsg(col: anki.collection.Collection, type: str, texpath: str) -> Any:
msg = col.tr(TR.MEDIA_ERROR_EXECUTING, val=type) + "<br>"
msg += col.tr(TR.MEDIA_GENERATED_FILE, val=texpath) + "<br>"
try:
with open(namedtmp("latex_log.txt", rm=False)) as f:
log = f.read()
if not log:
raise Exception()
msg += "<small><pre>" + html.escape(log) + "</pre></small>"
except:
msg += col.tr(TR.MEDIA_HAVE_YOU_INSTALLED_LATEX_AND_DVIPNGDVISVGM)
return msg
hooks.card_did_render.append(on_card_did_render)