77
88Everything here reports, never refuses: :func:`validate` returns what it found and the
99export goes ahead regardless, since a problem may well be deliberate.
10+
11+ Maths is rendered with KaTeX itself, which needs Node.js, and the set is compiled as the
12+ PDF generator compiles it, which needs pandoc and xelatex. Both are optional: without
13+ Node the maths check is skipped with a warning saying so, and without the compiler
14+ :mod:`in2lambda.validation.pdf` reports what to install.
1015"""
1116
17+ import json
1218import re
19+ import shutil
20+ import subprocess
21+ import warnings
1322from functools import cache
1423from pathlib import Path
24+ from typing import NamedTuple
1525
1626from in2lambda .api .problem import Problem
1727from in2lambda .api .question import Question
3444_DEGREES = re .compile (r"\^\s*\{?\s*\\circ" )
3545"""``^\\ circ``, with or without braces around it."""
3646
47+ _CHECK = Path (__file__ ).parent / "katex" / "check.js"
48+ """The Node script that renders expressions with the KaTeX packaged beside it."""
49+
50+
51+ class _Expression (NamedTuple ):
52+ """One piece of maths to render, and where in the set it was written."""
53+
54+ location : str
55+ start : int
56+ """Where the expression, opening delimiter included, begins in its field, from 1."""
57+ end : int
58+ tex : str
59+ display : bool
60+
3761
3862def validate (question_set : Set , compile : bool = True ) -> list [Problem ]:
3963 r"""Everything in2lambda can tell is wrong with a set, in the order it is written.
@@ -46,8 +70,10 @@ def validate(question_set: Set, compile: bool = True) -> list[Problem]:
4670
4771 Returns:
4872 One :class:`~in2lambda.api.problem.Problem` per problem found, each naming the
49- question, part and field to look at. An empty list means nothing was found -
50- not that the set will import, since only some mistakes can be seen from here.
73+ question, part and field to look at, in the order they are written - save for
74+ what KaTeX refused, which comes last because the whole set is rendered at once.
75+ An empty list means nothing was found - not that the set will import, since
76+ only some mistakes can be seen from here.
5177
5278 Examples:
5379 >>> from in2lambda.api.set import Set
@@ -59,16 +85,18 @@ def validate(question_set: Set, compile: bool = True) -> list[Problem]:
5985 """
6086 problems : list [Problem ] = []
6187 # Every markdown field with the location to report it against, kept so that the
62- # whole set can then be compiled in one go rather than a field at a time.
88+ # whole set can then be compiled in one go rather than a field at a time. The maths
89+ # is collected the same way, and rendered in one Node process.
6390 fields : list [tuple [str , str ]] = []
6491 images : list [str ] = []
92+ expressions : list [_Expression ] = []
6593
6694 def check (
6795 markdown : str , question : Question , location : str , compiled : bool = True
6896 ) -> list [Problem ]:
6997 if compiled :
7098 fields .append ((location , markdown ))
71- return _markdown_problems (markdown , question , location )
99+ return _markdown_problems (markdown , question , location , expressions )
72100
73101 for number , question in enumerate (question_set .questions , start = 1 ):
74102 where = f'Question { number } "{ question .title } "'
@@ -119,16 +147,22 @@ def check(
119147 if compile :
120148 problems += pdf .problems (fields , images )
121149
122- return problems
150+ return problems + _katex_rejections ( expressions )
123151
124152
125153def _markdown_problems (
126- markdown : str , question : Question , location : str
154+ markdown : str ,
155+ question : Question ,
156+ location : str ,
157+ expressions : list [_Expression ],
127158) -> list [Problem ]:
128159 """Every problem in one markdown field, reported against `location`.
129160
130161 The question is needed because an image reference is only good if that image is
131162 among the question's, and so will be written into the export's ``media/``.
163+
164+ The field's maths is appended to `expressions` rather than rendered here, so that
165+ the whole set takes one Node process instead of one per field.
132166 """
133167 problems : list [Problem ] = []
134168
@@ -144,41 +178,125 @@ def _markdown_problems(
144178 Problem (location , f"the export will not contain the image { reference } " )
145179 )
146180
147- problems += _katex_problems (markdown , location )
181+ problems += _katex_problems (markdown , location , expressions , delimiters )
148182 return problems
149183
150184
151- def _katex_problems (markdown : str , location : str ) -> list [Problem ]:
152- """Maths that KaTeX, which Lambda Feedback renders with, will not display."""
185+ def _katex_problems (
186+ markdown : str ,
187+ location : str ,
188+ expressions : list [_Expression ],
189+ delimiters : MathDelimiterError ,
190+ ) -> list [Problem ]:
191+ """Maths that KaTeX, which Lambda Feedback renders with, will not display.
192+
193+ Expressions the lists have nothing to say about are appended to `expressions` for
194+ KaTeX itself to render. The ones they do object to are not: their message says what
195+ to write instead, where KaTeX's only says what it choked on, and one fault reads
196+ better as one line.
197+ """
153198 problems : list [Problem ] = []
154199 lacks = _katex_lacks ()
155200
156201 for span in _MATHS .finditer (markdown ):
157- maths = span [1 ] if span [1 ] is not None else span [2 ]
158- for command in _COMMAND .findall (maths ):
159- if command in lacks :
160- replacement = lacks [command ]
161- problems .append (
162- Problem (
163- location ,
164- (
165- f"KaTeX does not render { command } ; write { replacement } instead"
166- if replacement
167- else f"KaTeX does not render { command } "
168- ),
169- )
202+ display = span [1 ] is not None
203+ maths = span [1 ] if display else span [2 ]
204+ unsupported = [
205+ command for command in _COMMAND .findall (maths ) if command in lacks
206+ ]
207+ for command in unsupported :
208+ replacement = lacks [command ]
209+ problems .append (
210+ Problem (
211+ location ,
212+ (
213+ f"KaTeX does not render { command } ; write { replacement } instead"
214+ if replacement
215+ else f"KaTeX does not render { command } "
216+ ),
170217 )
218+ )
171219 if _DEGREES .search (maths ):
172220 problems .append (
173221 Problem (
174222 location ,
175223 "^\\ circ does not display; write the degree sign ° instead" ,
176224 )
177225 )
226+ # Where the field's delimiters are wrong, what is between them is not reliably
227+ # the expression the author meant, so it is not rendered. The checks above are
228+ # reported against the field rather than a character range, so they still run.
229+ if not unsupported and delimiters is MathDelimiterError .PASSED :
230+ expressions .append (
231+ _Expression (location , span .start () + 1 , span .end (), maths , display )
232+ )
178233
179234 return problems
180235
181236
237+ def _katex_rejections (expressions : list [_Expression ]) -> list [Problem ]:
238+ """What KaTeX itself refuses to render, the whole set in one Node process.
239+
240+ Node is optional: someone authoring questions in Python should not have to install
241+ it, so without it this one check is skipped and says what to install instead.
242+ """
243+ if not expressions :
244+ return []
245+
246+ node = _node ()
247+ if node is None :
248+ warnings .warn (
249+ "Maths was not checked against KaTeX: install Node.js "
250+ "(https://nodejs.org) and run again" ,
251+ stacklevel = 3 ,
252+ )
253+ return []
254+
255+ try :
256+ rendered = subprocess .run (
257+ [node , str (_CHECK )],
258+ input = json .dumps (
259+ [
260+ {"tex" : expression .tex , "display" : expression .display }
261+ for expression in expressions
262+ ]
263+ ),
264+ capture_output = True ,
265+ # Not the locale's encoding: KaTeX marks where it stopped reading with
266+ # combining low lines, so its messages are never ASCII, and Node writes
267+ # them as UTF-8 whatever LANG says.
268+ encoding = "utf-8" ,
269+ check = True ,
270+ )
271+ rejections = json .loads (rendered .stdout )
272+ except (OSError , subprocess .SubprocessError , json .JSONDecodeError ) as error :
273+ # Anything named node on the PATH is run here, and it may not be Node.js at all.
274+ # Validation reports, never refuses, so a check that cannot be run says so and
275+ # leaves the rest of the report - and the export - alone.
276+ warnings .warn (
277+ f"Maths was not checked against KaTeX: running { node } failed ({ error } )" ,
278+ stacklevel = 3 ,
279+ )
280+ return []
281+
282+ problems : list [Problem ] = []
283+ for rejection in rejections :
284+ expression = expressions [rejection ["index" ]]
285+ problems .append (
286+ Problem (
287+ f"{ expression .location } , characters { expression .start } -{ expression .end } " ,
288+ f"KaTeX rejects it: { rejection ['message' ]} " ,
289+ )
290+ )
291+ return problems
292+
293+
294+ @cache
295+ def _node () -> str | None :
296+ """Where node is, or None if it is not installed."""
297+ return shutil .which ("node" )
298+
299+
182300@cache
183301def _katex_lacks () -> dict [str , str | None ]:
184302 """What KaTeX lacks, keyed by the command as it is written rather than as a regex.
0 commit comments