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
2 changes: 2 additions & 0 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ $ in2lambda questions.tex -a solutions.tex PartsSepSol

By default, this generates an `out` directory in the same place that the command was run in. It contains the zipped question files.

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.

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

## 3. Import into Lambda Feedback
Expand Down
24 changes: 24 additions & 0 deletions in2lambda/api/problem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Something wrong with a question set, and where in it to look."""

from dataclasses import dataclass


@dataclass(frozen=True)
class Problem:
"""Something in2lambda can tell Lambda Feedback will refuse or render wrongly.

``location`` names the question, part and field to go and look at; ``message``
says what is wrong with it.

Examples:
>>> from in2lambda.api.problem import Problem
>>> print(Problem('Question 1 "Drag", part (a), answer box 1', "no option is marked correct"))
Question 1 "Drag", part (a), answer box 1: no option is marked correct
"""

location: str
message: str

def __str__(self) -> str:
"""One line for the command line: ``<location>: <message>``."""
return f"{self.location}: {self.message}"
25 changes: 25 additions & 0 deletions in2lambda/api/set.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from dataclasses import dataclass, field
from typing import Any

from in2lambda.api.problem import Problem
from in2lambda.api.question import Question
from in2lambda.api.visibility_status import VisibilityController, VisibilityStatus

Expand Down Expand Up @@ -102,6 +103,30 @@ def increment_current_question(self) -> None:
"""
self._current_question_index += 1

def problems(self) -> list[Problem]:
r"""Everything in2lambda can tell Lambda Feedback would refuse or render wrongly.

This is a report, not a refusal: the set can still be written out, since a
problem found here may well be deliberate.

Returns:
One :class:`~in2lambda.api.problem.Problem` per problem found, each naming
the question, part and field to go and look at.

Examples:
>>> from in2lambda.api.set import Set
>>> s = Set()
>>> s.add_question("Momentum", "The rocket is at $45^\\circ$.")
>>> s.current_question.images.append("no_such_file.png")
>>> for problem in s.problems():
... print(problem)
Question 1 "Momentum", main text: ^\circ does not display; write the degree sign ° instead
Question 1 "Momentum": there is no image file at no_such_file.png
"""
from in2lambda.validation import validate

return validate(self)

def to_json(self, output_dir: str) -> None:
"""Turns this set into Lambda Feedback JSON/ZIP files.

Expand Down
9 changes: 4 additions & 5 deletions in2lambda/filters/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import panflute as pf
from beartype.typing import Callable, Optional
from rich_click import echo

from in2lambda.api.set import Set
from in2lambda.katex_convert.katex_convert import latex_to_katex
Expand Down Expand Up @@ -171,10 +170,10 @@ def markdown_converter(

case pf.Image:
# TODO: Handle "pdf images" and svg files.
path = image_path(elem.url, tex_file)
if path is None:
echo(f"Warning: Couldn't find {elem.url}")
else:
# An image that can't be found is left out of the question's images,
# which in2lambda.validation then reports against the question and
# part it is referenced from.
if (path := image_path(elem.url, tex_file)) is not None:
set.current_question.images.append(path)
return pf.Str(f"![pictureTag]({elem.url})")

Expand Down
65 changes: 45 additions & 20 deletions in2lambda/katex_convert/katex_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,41 +77,66 @@ def delete_functions(latex_string: str) -> str:
return latex_string


def replace_functions(latex_string: str) -> str:
"""Helper method of `latex_to_katex` that replaces some LaTeX expressions with an equivalent KaTeX one.
def unsupported_commands() -> dict[str, str | None]:
r"""Every LaTeX command KaTeX has no equivalent for, and what to write instead.

Args:
latex_string: A LaTeX string to be converted into valid KaTeX.
The keys and values are as ``delete_list.txt`` and ``replace_list.txt`` write them:
a regular expression and a :func:`re.sub` template, so a command's backslash is
escaped. ``None`` means the command has no equivalent and is simply dropped.

Only the plain ``\name`` lines of ``delete_list.txt`` are included, the rest of it
matching whole environments, lengths and stray braces rather than a command.

Returns:
The same LaTeX string with some commands replaced where necessary.
Each unsupported command mapped to its replacement, or to ``None`` if it has no
replacement.

Examples:
>>> from in2lambda.katex_convert.katex_convert import unsupported_commands
>>> commands = unsupported_commands()
>>> commands["\\\\norm"]
'\\\\mathbf'
>>> commands["\\\\bigskip"] is None
True
"""
replacement_dict = {} # Dictionary to store the formatted values
commands: dict[str, str | None] = {}

with open(Path(__file__).with_name("delete_list.txt"), "r") as file:
for line in file:
if re.fullmatch(r"\\\\[a-zA-Z]+", line.strip()):
commands[line.strip()] = None

with open(Path(__file__).with_name("replace_list.txt"), "r") as file:
for line in file:
line = line.strip().replace(",", "")
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
key, value = line.strip().replace(",", "").split(":", 1)
commands[key.strip()] = value.strip()

pattern = re.compile(key)
match = pattern.search(value)
return commands

if match:
key = key + "(?![a-zA-Z])"

replacement_dict[key] = value
def replace_functions(latex_string: str) -> str:
"""Helper method of `latex_to_katex` that replaces some LaTeX expressions with an equivalent KaTeX one.

Args:
latex_string: A LaTeX string to be converted into valid KaTeX.

Returns:
The same LaTeX string with some commands replaced where necessary.
"""
logger.info("")

# replace the incompatible functions with their KaTeX equivalents using re.sub
for old, new in replacement_dict.items():
for old, new in unsupported_commands().items():
if new is None: # Deleted rather than replaced; see delete_functions.
continue

# e.g. "\ang" must not match within its own replacement "\angle".
if re.search(old, new):
old = old + "(?![a-zA-Z])"

while re.search(old, latex_string):
match = re.search(old, latex_string)
if match:
logger.info(f"Replaced {old} with {new}")
latex_string = re.sub(old, new, latex_string)
logger.info(f"Replaced {old} with {new}")
latex_string = re.sub(old, new, latex_string)
return latex_string


Expand Down
5 changes: 5 additions & 0 deletions in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ def runner(
parsing_answers=True,
)

# 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():
click.echo(f"Warning: {problem}")

# Read the Python API format and convert to JSON.
if output_dir is not None:
set_obj.to_json(output_dir)
Expand Down
Loading
Loading