diff --git a/docs/source/filters.py b/docs/source/filters.py index 2002768..3879d61 100644 --- a/docs/source/filters.py +++ b/docs/source/filters.py @@ -37,20 +37,31 @@ def generate_filters_docs(): If absolute were needed: f"{os.path.dirname(filter_module.__file__)}/filename" """ filter_file = f"{relative_directory}/filter.py" - tex_file = f"{relative_directory}/example.tex" + + # Filters ship either a LaTeX example (rendered to an embedded PDF) or, + # for the plain-markdown filter, a markdown example shown inline. + source_directory = Path(os.path.dirname(filter_module.__file__)) + if (source_directory / "example.tex").is_file(): + example_file = f"{relative_directory}/example.tex" + example_language = "LaTeX" + example_is_latex = True + else: + example_file = f"{relative_directory}/example.md" + example_language = "markdown" + example_is_latex = False # Different path likely needed since GitHub Actions builds with dirhtml builder. # This is relative to the auto-generated filter file. pdf_file = f"../../{'../' if os.getenv('GITHUB_ACTIONS') == 'true' else './'}{static_pdf_directory}/{filter_name}.pdf" - if shutil.which("pdflatex"): + if example_is_latex and shutil.which("pdflatex"): subprocess.run( [ "pdflatex", f"-output-directory={static_pdf_directory}", f"-jobname={filter_name}", "-interaction=nonstopmode", - tex_file, + example_file, ], check=True, ) @@ -58,6 +69,25 @@ def generate_filters_docs(): if not os.path.exists(f"{static_pdf_directory}/{filter_name}.pdf"): raise RuntimeError("PDF output not found") + if example_is_latex: + example_rst = f"""\ +A PDF which this filter parses correctly is shown below: + +.. dropdown:: ๐Ÿ“„ LaTeX Code + + .. literalinclude:: {example_file} + :language: {example_language} + +:pdfembed:`src: {pdf_file}, height:700, width:100%, align:middle` +""" + else: + example_rst = f"""\ +A markdown document which this filter parses correctly is shown below: + +.. literalinclude:: {example_file} + :language: {example_language} +""" + rst_content = f"""\ {filter_name} {'*' * len(filter_name)} @@ -67,15 +97,7 @@ def generate_filters_docs(): Minimal Example ---------------- -A PDF which this filter parses correctly is shown below: - -.. dropdown:: ๐Ÿ“„ LaTeX Code - - .. literalinclude:: {tex_file} - :language: LaTeX - -:pdfembed:`src: {pdf_file}, height:700, width:100%, align:middle` - +{example_rst} .. dropdown:: ๐Ÿ Python Filter .. literalinclude:: {filter_file} diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 34fd175..194f94e 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -69,6 +69,12 @@ Another filter might be used if [the answers are in a separate file](filters/_au $ in2lambda questions.tex -a solutions.tex PartsSepSol ``` +If you would rather write the questions yourself, the [`Markdown` filter](filters/_autosummary/Markdown) reads a plain markdown file where `#` starts a question, `##` starts a part, and `## Solution` gives a worked solution: + +```bash +$ in2lambda questions.md Markdown +``` + By default, this generates an `out` directory in the same place that the command was run in. It contains the zipped question files. Check the [command line tool reference](reference/command-line) for more information. diff --git a/in2lambda/filters/Markdown/__init__.py b/in2lambda/filters/Markdown/__init__.py new file mode 100644 index 0000000..adcf12c --- /dev/null +++ b/in2lambda/filters/Markdown/__init__.py @@ -0,0 +1 @@ +"""Filter for question sets hand-authored (or wizard-generated) in plain markdown.""" diff --git a/in2lambda/filters/Markdown/example.md b/in2lambda/filters/Markdown/example.md new file mode 100644 index 0000000..0ec4a3c --- /dev/null +++ b/in2lambda/filters/Markdown/example.md @@ -0,0 +1,40 @@ +# Projectile motion + +A ball is thrown horizontally from a height of $h = 20\,\text{m}$ with speed +$v_0 = 15\,\text{m/s}$. Take $g = 9.8\,\text{m/s}^2$. + +## Time of flight + +How long does the ball take to reach the ground, using: + +- the vertical motion equation +- the given height and gravity + +## Solution + +Vertical motion is independent of the horizontal throw: + +$$ +h = \frac{1}{2} g t^2 \implies t = \sqrt{\frac{2h}{g}} +$$ + +--- + +So $t \approx 2.0\,\text{s}$. + +## Horizontal range + +How far from the launch point does the ball land? + +## Solution + +$x = v_0 t \approx 30\,\text{m}$. + +# Newton's second law + +State Newton's second law of motion and give its equation. + +## Solution + +The net force on a body equals the rate of change of its momentum; for constant +mass this is $F = m a$. diff --git a/in2lambda/filters/Markdown/filter.py b/in2lambda/filters/Markdown/filter.py new file mode 100644 index 0000000..660cb82 --- /dev/null +++ b/in2lambda/filters/Markdown/filter.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 + +r"""Questions written directly in markdown, with a ``#``/``##`` structure. + +The document is a flat sequence of headings and body blocks: + +* A level-1 heading (``#``) starts a **new question**. Its text becomes the + question title; the blocks that follow it (until the next heading) become the + top-level question text. +* A level-2 heading (``##``) whose text is not ``Solution`` starts a **new part** + of the current question. The blocks that follow become the part text. +* A level-2 heading (``##``) whose text is ``Solution`` (case-insensitive) marks + the blocks that follow as the **worked solution** for the current part, or for + the whole question if it has no parts yet. + +When a separate answers file is supplied via ``-a``, a level-1 heading advances +to the next question and every body block is added as a worked solution +(:meth:`~in2lambda.api.question.Question.add_solution` spreads it across the +question's parts). + +This is the format the ``in2lambda wizard`` command emits, and the validator in +:mod:`in2lambda.validation` checks it before conversion. +""" + +from typing import Optional + +import panflute as pf + +from in2lambda.api.part import Part +from in2lambda.api.set import Set +from in2lambda.filters.markdown import filter + +_SOLUTION_HEADING = "solution" + + +class _State: + """Where the next body block should go, tracked while walking one document.""" + + def __init__(self) -> None: + self.target = "main" # "main" | "part" | "solution" + self.part: Optional[Part] = None + + +def _state_for(doc: pf.Doc) -> _State: + """Return the parser state for ``doc``, resetting it when a new document starts. + + panflute has no per-run hook, so state is kept on the function object and + refreshed whenever the document object identity changes (e.g. the question + file followed by a separate answers file). + """ + if getattr(pandoc_filter, "_doc", None) is not doc: + pandoc_filter._doc = doc + pandoc_filter._state = _State() + return pandoc_filter._state + + +def _append(current: str, addition: str) -> str: + """Join two blocks of text with a blank line, ignoring empty additions.""" + addition = addition.strip() + if not addition: + return current + return f"{current}\n\n{addition}" if current else addition + + +@filter +def pandoc_filter( + elem: pf.Element, + doc: pf.elements.Doc, + set: Set, + parsing_answers: bool, +) -> Optional[pf.Inline]: + """Turn a ``#``/``##`` markdown document into questions, parts and solutions. + + Args: + elem: The current element being processed. + doc: The Pandoc document container. + set: The Python API used to store the parsed result. + parsing_answers: Whether an answers-only document is being parsed. + + Returns: + Always ``None`` - this filter records into ``set`` rather than rewriting + the AST (inline rewriting is handled by the shared markdown decorator). + """ + # Only act on top-level blocks; inline elements are handled by @filter. + if not isinstance(elem, pf.Block) or not isinstance(elem.parent, pf.Doc): + return None + + state = _state_for(doc) + is_heading = isinstance(elem, pf.Header) + is_rule = isinstance(elem, pf.HorizontalRule) + + if is_heading: + text = pf.stringify(elem).strip() + elif is_rule: + # HorizontalRule blocks (``---``) stringify to nothing, so they're matched + # separately and kept as literal text: in a Lambda Feedback worked solution + # they mark the boundary between the steps a student clicks through. + text = "---" + else: + # Serialized back to markdown (rather than flattened with pf.stringify) so + # that lists, tables and other markup survive into the question/part/ + # solution text verbatim. + text = pf.convert_text( + elem, input_format="panflute", output_format="markdown" + ).strip() + + if parsing_answers: + if is_heading and elem.level == 1: + set.increment_current_question() + elif (is_rule or not is_heading) and text: + set.current_question.add_solution(text) + return None + + if is_heading and elem.level == 1: + set.add_question(title=text) + state.target, state.part = "main", None + elif is_heading and elem.level == 2 and text.lower() == _SOLUTION_HEADING: + if state.part is None: + state.part = Part() + set.current_question.parts.append(state.part) + state.target = "solution" + elif is_heading and elem.level == 2: + state.part = Part() + set.current_question.parts.append(state.part) + state.target = "part" + elif (is_rule or not is_heading) and text: + if state.target == "main": + set.current_question.main_text = text + elif state.target == "part" and state.part is not None: + state.part.text = _append(state.part.text, text) + elif state.target == "solution" and state.part is not None: + state.part.worked_solution = _append(state.part.worked_solution, text) + + return None diff --git a/in2lambda/filters/markdown.py b/in2lambda/filters/markdown.py index 789aabd..4237263 100644 --- a/in2lambda/filters/markdown.py +++ b/in2lambda/filters/markdown.py @@ -118,11 +118,11 @@ def image_path(image_name: str, tex_file: str) -> Optional[str]: def filter( func: Callable[ [pf.Element, pf.elements.Doc, Set, bool], - Optional[pf.Str], + Optional[pf.Inline], ] ) -> Callable[ [pf.Element, pf.elements.Doc, Set, str, bool], - Optional[pf.Str], + Optional[pf.Inline], ]: """Python decorator to make generic LaTeX elements markdown readable. @@ -139,7 +139,7 @@ def markdown_converter( set: Set, tex_file: str, parsing_answers: bool, - ) -> Optional[pf.Str]: + ) -> Optional[pf.Inline]: """Handles LaTeX elements within the filter, before calling the original function. N.B. tex_file is required to determine where the relative image directory is. @@ -163,10 +163,13 @@ def markdown_converter( expression = latex_to_katex(elem.text) except Exception: expression = elem.text - return pf.Str( - f"${expression}$" - if elem.format == "InlineMath" - else f"\n\n$$\n{expression}\n$$\n\n" + return pf.RawInline( + ( + f"${expression}$" + if elem.format == "InlineMath" + else f"\n\n$$\n{expression}\n$$\n\n" + ), + format="markdown", ) case pf.Image: @@ -176,13 +179,13 @@ def markdown_converter( echo(f"Warning: Couldn't find {elem.url}") else: set.current_question.images.append(path) - return pf.Str(f"![pictureTag]({elem.url})") + return pf.RawInline(f"![pictureTag]({elem.url})", format="markdown") case pf.Strong: - return pf.Str(f"**{pf.stringify(elem)}**") + return pf.RawInline(f"**{pf.stringify(elem)}**", format="markdown") case pf.Emph: - return pf.Str(f"*{pf.stringify(elem)}*") + return pf.RawInline(f"*{pf.stringify(elem)}*", format="markdown") # Replace siunitx no-break space with narrow no-break space # This should be the space between the number and the units diff --git a/in2lambda/main.py b/in2lambda/main.py index d5df1e2..c148e1e 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -16,6 +16,13 @@ import in2lambda.filters from in2lambda.api.set import Set +from in2lambda.validation import check_markdown + + +def _warn_markdown_issues(text: str, source: str) -> None: + """Echo a warning for each math-delimiter problem found in a markdown source.""" + for problem in check_markdown(text): + click.echo(f"Warning: {source}: {problem}") def docx_to_md(docx_file: str) -> str: @@ -120,6 +127,9 @@ def runner( input_format = file_type(question_file) + if input_format == "markdown": + _warn_markdown_issues(text, question_file) + # Parse the Pandoc AST using the relevant panflute filter. pf.run_filter( filter_module.pandoc_filter, @@ -141,6 +151,9 @@ def runner( answer_text = file.read() answer_format = file_type(answer_file) + if answer_format == "markdown": + _warn_markdown_issues(answer_text, answer_file) + pf.run_filter( filter_module.pandoc_filter, doc=pf.convert_text( diff --git a/in2lambda/validation/__init__.py b/in2lambda/validation/__init__.py index b4400c0..d451836 100644 --- a/in2lambda/validation/__init__.py +++ b/in2lambda/validation/__init__.py @@ -1,4 +1,4 @@ -"""Pre-flight checks for the ``#``/``##`` markdown that flows through in2lambda. +"""Pre-flight checks for the markdown that flows through in2lambda. The markdown produced by the wizard (and hand-written by users) is the shared contract between the wizard, the ``Markdown`` filter and Lambda Feedback. These diff --git a/in2lambda/validation/delimiters.py b/in2lambda/validation/delimiters.py index 10b8014..343b79a 100644 --- a/in2lambda/validation/delimiters.py +++ b/in2lambda/validation/delimiters.py @@ -1,4 +1,4 @@ -"""Checks that ``$ ... $`` and ``$$ ... $$`` math delimiters are balanced and placed correctly. +"""Check that inline and display math delimiters are balanced and placed correctly. Lambda Feedback renders inline math (via KaTeX) wrapped in single dollar signs, and display math wrapped in ``$$``. Requiring each ``$$`` delimiter to diff --git a/tests/test_markdown_filter.py b/tests/test_markdown_filter.py new file mode 100644 index 0000000..c5b5550 --- /dev/null +++ b/tests/test_markdown_filter.py @@ -0,0 +1,78 @@ +"""Tests for the ``Markdown`` filter (hand-authored ``#``/``##`` question sets).""" + +import json +import os + +from in2lambda.main import runner + + +def _example(filters_dir: str) -> str: + return os.path.join(filters_dir, "Markdown", "example.md") + + +def test_example_parses_into_questions_parts_and_solutions(filters_dir: str) -> None: + result = runner(_example(filters_dir), "Markdown") + + assert [q.title for q in result.questions] == [ + "Projectile motion", + "Newtonโ€™s second law", + ] + + projectile = result.questions[0] + assert projectile.main_text.startswith("A ball is thrown horizontally") + assert projectile.parts[0].text == ( + "How long does the ball take to reach the ground, using:\n\n" + "- the vertical motion equation\n- the given height and gravity" + ) + assert ( + projectile.parts[1].text == "How far from the launch point does the ball land?" + ) + assert projectile.parts[0].worked_solution.startswith("Vertical motion is") + # The ``---`` separator survives as a literal step boundary. + assert "\n\n---\n\n" in projectile.parts[0].worked_solution + assert "v_0 t" in projectile.parts[1].worked_solution + + # A question with no ``##`` parts keeps its solution on a single empty part. + newton = result.questions[1] + assert newton.parts[0].text == "" + assert "F = m a" in newton.parts[0].worked_solution + + +def test_markdown_filter_writes_importable_json(filters_dir: str, tmp_path) -> None: + out_dir = tmp_path / "out" + runner(_example(filters_dir), "Markdown", str(out_dir)) + + question_files = sorted((out_dir / "set").glob("question_*.json")) + assert len(question_files) == 2 + first = json.loads(question_files[0].read_text()) + assert first["title"] == "Projectile motion" + assert first["parts"][0]["content"].startswith( + "How long does the ball take to reach the ground, using:" + ) + assert "- the vertical motion equation" in first["parts"][0]["content"] + assert first["parts"][0]["workedSolution"]["content"].startswith( + "Vertical motion is" + ) + assert "\n\n---\n\n" in first["parts"][0]["workedSolution"]["content"] + + +def test_bad_math_delimiters_warn_but_do_not_fail(tmp_path, capsys) -> None: + bad = tmp_path / "bad.md" + bad.write_text("# Q\n\nText with a stray $ sign and no closing delimiter") + + result = runner(str(bad), "Markdown") + + assert result.questions[0].title == "Q" + assert "unclosed inline" in capsys.readouterr().out + + +def test_separate_answers_file_fills_worked_solutions(tmp_path) -> None: + questions = tmp_path / "q.md" + questions.write_text("# Q1\n\nFirst question.\n\n# Q2\n\nSecond question.\n") + answers = tmp_path / "a.md" + answers.write_text("# Q1\n\nAnswer to one.\n\n# Q2\n\nAnswer to two.\n") + + result = runner(str(questions), "Markdown", answer_file=str(answers)) + + assert result.questions[0].parts[0].worked_solution == "Answer to one." + assert result.questions[1].parts[0].worked_solution == "Answer to two."