Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ jobs:
poetry install --with dev --all-extras
- name: Install Pandoc # apt version seems too old
uses: r-lib/actions/setup-pandoc@v2
- name: Install Node # Without it, the maths is not rendered and its tests skip
uses: actions/setup-node@v4
with:
node-version: '22'
# The validator compiles a set the way lambda-feedback/PDF-generator does, so it
# needs what that image installs: xelatex with braket, cancel, xeCJK and the
# Noto Sans fonts the template sets as its main and CJK fonts.
Expand Down
3 changes: 3 additions & 0 deletions docs/source/contributing/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ $ pre-commit install
$ in2lambda --help
```

The test suite also renders maths with KaTeX, so install [Node.js](https://nodejs.org) to run all of
it; the tests that need it skip without it.

To exit the environment:
```shell
$ exit
Expand Down
2 changes: 2 additions & 0 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ By default, this generates an `out` directory in the same place that the command

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.

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.

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.

Check the [command line tool reference](reference/command-line) for more information.
Expand Down
12 changes: 10 additions & 2 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import getpass
import importlib
import shlex
import warnings
from collections.abc import ( # Rather than typing's, which beartype warns on.
Callable,
Iterator,
Expand Down Expand Up @@ -141,9 +142,16 @@ def runner(
)

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

# Read the Python API format and convert to JSON.
if output_dir is not None:
Expand Down
162 changes: 140 additions & 22 deletions in2lambda/validation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@

Everything here reports, never refuses: :func:`validate` returns what it found and the
export goes ahead regardless, since a problem may well be deliberate.

Maths is rendered with KaTeX itself, which needs Node.js, and the set is compiled as the
PDF generator compiles it, which needs pandoc and xelatex. Both are optional: without
Node the maths check is skipped with a warning saying so, and without the compiler
:mod:`in2lambda.validation.pdf` reports what to install.
"""

import json
import re
import shutil
import subprocess
import warnings
from functools import cache
from pathlib import Path
from typing import NamedTuple

from in2lambda.api.problem import Problem
from in2lambda.api.question import Question
Expand All @@ -34,6 +44,20 @@
_DEGREES = re.compile(r"\^\s*\{?\s*\\circ")
"""``^\\circ``, with or without braces around it."""

_CHECK = Path(__file__).parent / "katex" / "check.js"
"""The Node script that renders expressions with the KaTeX packaged beside it."""


class _Expression(NamedTuple):
"""One piece of maths to render, and where in the set it was written."""

location: str
start: int
"""Where the expression, opening delimiter included, begins in its field, from 1."""
end: int
tex: str
display: bool


def validate(question_set: Set, compile: bool = True) -> list[Problem]:
r"""Everything in2lambda can tell is wrong with a set, in the order it is written.
Expand All @@ -46,8 +70,10 @@ def validate(question_set: Set, compile: bool = True) -> list[Problem]:

Returns:
One :class:`~in2lambda.api.problem.Problem` per problem found, each naming the
question, part and field to look at. An empty list means nothing was found -
not that the set will import, since only some mistakes can be seen from here.
question, part and field to look at, in the order they are written - save for
what KaTeX refused, which comes last because the whole set is rendered at once.
An empty list means nothing was found - not that the set will import, since
only some mistakes can be seen from here.

Examples:
>>> from in2lambda.api.set import Set
Expand All @@ -59,16 +85,18 @@ def validate(question_set: Set, compile: bool = True) -> list[Problem]:
"""
problems: list[Problem] = []
# Every markdown field with the location to report it against, kept so that the
# whole set can then be compiled in one go rather than a field at a time.
# whole set can then be compiled in one go rather than a field at a time. The maths
# is collected the same way, and rendered in one Node process.
fields: list[tuple[str, str]] = []
images: list[str] = []
expressions: list[_Expression] = []

def check(
markdown: str, question: Question, location: str, compiled: bool = True
) -> list[Problem]:
if compiled:
fields.append((location, markdown))
return _markdown_problems(markdown, question, location)
return _markdown_problems(markdown, question, location, expressions)

for number, question in enumerate(question_set.questions, start=1):
where = f'Question {number} "{question.title}"'
Expand Down Expand Up @@ -119,16 +147,22 @@ def check(
if compile:
problems += pdf.problems(fields, images)

return problems
return problems + _katex_rejections(expressions)


def _markdown_problems(
markdown: str, question: Question, location: str
markdown: str,
question: Question,
location: str,
expressions: list[_Expression],
) -> list[Problem]:
"""Every problem in one markdown field, reported against `location`.

The question is needed because an image reference is only good if that image is
among the question's, and so will be written into the export's ``media/``.

The field's maths is appended to `expressions` rather than rendered here, so that
the whole set takes one Node process instead of one per field.
"""
problems: list[Problem] = []

Expand All @@ -144,41 +178,125 @@ def _markdown_problems(
Problem(location, f"the export will not contain the image {reference}")
)

problems += _katex_problems(markdown, location)
problems += _katex_problems(markdown, location, expressions, delimiters)
return problems


def _katex_problems(markdown: str, location: str) -> list[Problem]:
"""Maths that KaTeX, which Lambda Feedback renders with, will not display."""
def _katex_problems(
markdown: str,
location: str,
expressions: list[_Expression],
delimiters: MathDelimiterError,
) -> list[Problem]:
"""Maths that KaTeX, which Lambda Feedback renders with, will not display.

Expressions the lists have nothing to say about are appended to `expressions` for
KaTeX itself to render. The ones they do object to are not: their message says what
to write instead, where KaTeX's only says what it choked on, and one fault reads
better as one line.
"""
problems: list[Problem] = []
lacks = _katex_lacks()

for span in _MATHS.finditer(markdown):
maths = span[1] if span[1] is not None else span[2]
for command in _COMMAND.findall(maths):
if command in lacks:
replacement = lacks[command]
problems.append(
Problem(
location,
(
f"KaTeX does not render {command}; write {replacement} instead"
if replacement
else f"KaTeX does not render {command}"
),
)
display = span[1] is not None
maths = span[1] if display else span[2]
unsupported = [
command for command in _COMMAND.findall(maths) if command in lacks
]
for command in unsupported:
replacement = lacks[command]
problems.append(
Problem(
location,
(
f"KaTeX does not render {command}; write {replacement} instead"
if replacement
else f"KaTeX does not render {command}"
),
)
)
if _DEGREES.search(maths):
problems.append(
Problem(
location,
"^\\circ does not display; write the degree sign ° instead",
)
)
# Where the field's delimiters are wrong, what is between them is not reliably
# the expression the author meant, so it is not rendered. The checks above are
# reported against the field rather than a character range, so they still run.
if not unsupported and delimiters is MathDelimiterError.PASSED:
expressions.append(
_Expression(location, span.start() + 1, span.end(), maths, display)
)

return problems


def _katex_rejections(expressions: list[_Expression]) -> list[Problem]:
"""What KaTeX itself refuses to render, the whole set in one Node process.

Node is optional: someone authoring questions in Python should not have to install
it, so without it this one check is skipped and says what to install instead.
"""
if not expressions:
return []

node = _node()
if node is None:
warnings.warn(
"Maths was not checked against KaTeX: install Node.js "
"(https://nodejs.org) and run again",
stacklevel=3,
)
return []

try:
rendered = subprocess.run(
[node, str(_CHECK)],
input=json.dumps(
[
{"tex": expression.tex, "display": expression.display}
for expression in expressions
]
),
capture_output=True,
# Not the locale's encoding: KaTeX marks where it stopped reading with
# combining low lines, so its messages are never ASCII, and Node writes
# them as UTF-8 whatever LANG says.
encoding="utf-8",
check=True,
)
rejections = json.loads(rendered.stdout)
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as error:
# Anything named node on the PATH is run here, and it may not be Node.js at all.
# Validation reports, never refuses, so a check that cannot be run says so and
# leaves the rest of the report - and the export - alone.
warnings.warn(
f"Maths was not checked against KaTeX: running {node} failed ({error})",
stacklevel=3,
)
return []

problems: list[Problem] = []
for rejection in rejections:
expression = expressions[rejection["index"]]
problems.append(
Problem(
f"{expression.location}, characters {expression.start}-{expression.end}",
f"KaTeX rejects it: {rejection['message']}",
)
)
return problems


@cache
def _node() -> str | None:
"""Where node is, or None if it is not installed."""
return shutil.which("node")


@cache
def _katex_lacks() -> dict[str, str | None]:
"""What KaTeX lacks, keyed by the command as it is written rather than as a regex.
Expand Down
21 changes: 21 additions & 0 deletions in2lambda/validation/katex/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2013-2020 Khan Academy and other contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
7 changes: 7 additions & 0 deletions in2lambda/validation/katex/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# KaTeX, as shipped

`katex.min.js` is `dist/katex.min.js` from the katex npm package, version 0.18.7, copied verbatim,
with KaTeX's own `LICENSE` beside it. `check.js` is ours and renders with it.

To move to a newer version: download the tarball (`npm pack katex@<version>`), copy `dist/katex.min.js`
and `LICENSE` out of it over these two, and change the version named above.
39 changes: 39 additions & 0 deletions in2lambda/validation/katex/check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Renders maths with KaTeX itself, so that what Lambda Feedback's browser would refuse
// is refused here. Reads a JSON array of {"tex": ..., "display": bool} from stdin and
// writes a JSON array of {"index": i, "message": ...} for the ones KaTeX threw on.
//
// One process renders every expression in a set: starting node costs more than the
// rendering does.

const katex = require("./katex.min.js");

let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => (input += chunk));
process.stdin.on("end", () => {
const rejections = [];

JSON.parse(input).forEach((expression, index) => {
try {
katex.renderToString(expression.tex, {
displayMode: expression.display,
strict: true,
throwOnError: true,
});
} catch (error) {
rejections.push({
index,
message: error.message
// Every message begins "KaTeX parse error: "; the report already says who is
// speaking, so the constant part is dropped here.
.replace(/^KaTeX parse error: /, "")
// The message quotes the maths it choked on, which for display maths runs
// over lines, and a reported problem is one line.
.replace(/\s*\n\s*/g, " ")
.trim(),
});
}
});

process.stdout.write(JSON.stringify(rejections));
});
1 change: 1 addition & 0 deletions in2lambda/validation/katex/katex.min.js

Large diffs are not rendered by default.

Loading
Loading