Skip to content

Commit 165e253

Browse files
committed
Merge commit '12b5d823dd01afe833206a878573c815976560c3' into wb/t34
2 parents 711df8d + 12b5d82 commit 165e253

29 files changed

Lines changed: 484 additions & 26 deletions

‎.github/workflows/test.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ jobs:
2222
poetry install --with dev --all-extras
2323
- name: Install Pandoc # apt version seems too old
2424
uses: r-lib/actions/setup-pandoc@v2
25+
- name: Install Node # Without it, the maths is not rendered and its tests skip
26+
uses: actions/setup-node@v4
27+
with:
28+
node-version: '22'
2529
# The validator compiles a set the way lambda-feedback/PDF-generator does, so it
2630
# needs what that image installs: xelatex with braket, cancel, xeCJK and the
2731
# Noto Sans fonts the template sets as its main and CJK fonts.

‎docs/source/contributing/installation.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ $ pre-commit install
2121
$ in2lambda --help
2222
```
2323

24+
The test suite also renders maths with KaTeX, so install [Node.js](https://nodejs.org) to run all of
25+
it; the tests that need it skip without it.
26+
2427
To exit the environment:
2528
```shell
2629
$ exit

‎docs/source/quickstart.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ By default, this generates an `out` directory in the same place that the command
7575

7676
Before writing anything, in2lambda prints the problems it can detect that would stop the set importing or make it render wrongly — an answer that doesn't fit the box marking it, a figure the export won't contain, maths that KaTeX can't display. Each names the question, part and field to go and look at. They are warnings rather than errors: the `out` directory is written either way, since a problem found here may well be deliberate.
7777

78+
The maths is checked by rendering it with KaTeX itself, the way Lambda Feedback will, which needs [Node.js](https://nodejs.org) installed. Without Node.js everything else is still checked and in2lambda says the maths was not.
79+
7880
With [xelatex](https://tug.org/texlive/) installed alongside pandoc, the set is also compiled the way Lambda Feedback makes a PDF of it, and any LaTeX error names the field it is in. Without it, one warning says which packages to install instead.
7981

8082
Check the [command line tool reference](reference/command-line) for more information.

‎in2lambda/main.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import getpass
1010
import importlib
1111
import shlex
12+
import warnings
1213
from collections.abc import ( # Rather than typing's, which beartype warns on.
1314
Callable,
1415
Iterator,
@@ -141,9 +142,16 @@ def runner(
141142
)
142143

143144
# Report before writing anything: the problems are the set's whether or not it is
144-
# written out, and an author reading the command line should see them first.
145-
for problem in set_obj.problems():
145+
# written out, and an author reading the command line should see them first. A check
146+
# that could not be run at all - the maths, with no Node.js to render it - warns
147+
# instead, and is caught here so that it reads as a line rather than a traceback.
148+
with warnings.catch_warnings(record=True) as not_checked:
149+
warnings.simplefilter("always")
150+
problems = set_obj.problems()
151+
for problem in problems:
146152
click.echo(f"Warning: {problem}")
153+
for warning in not_checked:
154+
click.echo(f"Warning: {warning.message}")
147155

148156
# Read the Python API format and convert to JSON.
149157
if output_dir is not None:

‎in2lambda/validation/__init__.py‎

Lines changed: 140 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@
77
88
Everything here reports, never refuses: :func:`validate` returns what it found and the
99
export 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
1218
import re
19+
import shutil
20+
import subprocess
21+
import warnings
1322
from functools import cache
1423
from pathlib import Path
24+
from typing import NamedTuple
1525

1626
from in2lambda.api.problem import Problem
1727
from in2lambda.api.question import Question
@@ -34,6 +44,20 @@
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

3862
def 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

125153
def _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
183301
def _katex_lacks() -> dict[str, str | None]:
184302
"""What KaTeX lacks, keyed by the command as it is written rather than as a regex.

‎in2lambda/validation/katex/LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2013-2020 Khan Academy and other contributors
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# KaTeX, as shipped
2+
3+
`katex.min.js` is `dist/katex.min.js` from the katex npm package, version 0.18.7, copied verbatim,
4+
with KaTeX's own `LICENSE` beside it. `check.js` is ours and renders with it.
5+
6+
To move to a newer version: download the tarball (`npm pack katex@<version>`), copy `dist/katex.min.js`
7+
and `LICENSE` out of it over these two, and change the version named above.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Renders maths with KaTeX itself, so that what Lambda Feedback's browser would refuse
2+
// is refused here. Reads a JSON array of {"tex": ..., "display": bool} from stdin and
3+
// writes a JSON array of {"index": i, "message": ...} for the ones KaTeX threw on.
4+
//
5+
// One process renders every expression in a set: starting node costs more than the
6+
// rendering does.
7+
8+
const katex = require("./katex.min.js");
9+
10+
let input = "";
11+
process.stdin.setEncoding("utf8");
12+
process.stdin.on("data", (chunk) => (input += chunk));
13+
process.stdin.on("end", () => {
14+
const rejections = [];
15+
16+
JSON.parse(input).forEach((expression, index) => {
17+
try {
18+
katex.renderToString(expression.tex, {
19+
displayMode: expression.display,
20+
strict: true,
21+
throwOnError: true,
22+
});
23+
} catch (error) {
24+
rejections.push({
25+
index,
26+
message: error.message
27+
// Every message begins "KaTeX parse error: "; the report already says who is
28+
// speaking, so the constant part is dropped here.
29+
.replace(/^KaTeX parse error: /, "")
30+
// The message quotes the maths it choked on, which for display maths runs
31+
// over lines, and a reported problem is one line.
32+
.replace(/\s*\n\s*/g, " ")
33+
.trim(),
34+
});
35+
}
36+
});
37+
38+
process.stdout.write(JSON.stringify(rejections));
39+
});

‎in2lambda/validation/katex/katex.min.js‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)