diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67b86b3..d293156 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@
- An export now names its images by the file names `media/` holds them under. Every markdown image reference a question holds — in the question's text, a part's text, a worked solution, a final answer or an answer box's wording — is rewritten to the file name the image was carried under, so a document writing `` exports as `` beside `media/train.png`, and Lambda Feedback finds the figure where it looks for one. A file two questions use is carried once. Where two different files have the same name, in2lambda names the second as Lambda Feedback's own exports name an image, `question_001_
_0001.png`. Reading an export back is unchanged, because an export already names its images this way.
- A draft can freeze more than one document, which is how a sheet written as a question file and a separate solutions file is drafted. `in2lambda source add questions.docx solutions.docx` freezes them as source 1 and source 2 of the one `questions.draft.json`, and `in2lambda source add solutions.docx --draft questions.docx` adds a file to an existing draft as its next source. Every block id and line range of a source after the first carries that source's number — `2/b3`, `2/s10:14` — and `1/b3` names the block `b3` names. A field quoted from a source records which source it came from, so that the same line number in two documents is two places. `in2lambda source show` prints each source under its number and its name. `in2lambda spec run` runs the spec over every source: the first source is laid out as the spec's `layout` says, and in any source after it the `question` selector picks out the marker written above each question's solutions while every other match is a solution, paired onto the questions and parts of the first source as `in2lambda convert -a` pairs an answers file. A draft now holds `sources`, a list of `{source, hash, blocks}` in the order they were frozen, in place of those three keys at the top level, so a draft written before this release is refused as a draft in2lambda did not write; `in2lambda source add --start-over` freezes the document again. Four changes to the Python API break existing scripts: `in2lambda.source.add` takes a list of files and the draft to freeze them into; `in2lambda.source.frozen` returns the markdown of every source and `in2lambda.draft.apply` takes the markdown of every source, in place of one; `in2lambda.spec.fields` takes one `(blocks, markdown)` pair per source in place of its `elements` and `markdown` arguments; and `in2lambda.spec.Field` carries the number of the source its ranges are lines of, which every caller constructing a `Field` must pass.
- Every command that works on a draft takes `--draft`, naming either the draft or the source it was frozen from: `in2lambda source show`, each `in2lambda draft` command, `in2lambda spec run`, `in2lambda validate`, `in2lambda build` and `in2lambda render`. Left off, each command uses the one draft in the current directory, and where the directory holds more than one draft, the command is refused, naming them. `in2lambda spec run` resolves its SPEC from the draft's directory. The Python functions behind those commands take the draft's path in place of a directory: `in2lambda.source.frozen`, `in2lambda.source.show`, `in2lambda.draft.execute`, `in2lambda.draft.replay`, `in2lambda.draft.spec_command`, `in2lambda.draft.report.validate`, `in2lambda.draft.export.build` and `in2lambda.draft.export.render`. `in2lambda.source.draft_of` returns the path of a document's draft, and `in2lambda.source.find` resolves `--draft` for the command line.
+- `in2lambda convert FILE PartsOneSol` now exports the worked solution a document writes in a `solution` environment. Pandoc writes that environment as a Div whose classes hold `solution`, and the filter recognised only a Div whose first block reads `Solution`, so a document using the environment exported every question with an empty worked solution. A Div whose first block reads `Solution` is still recognised.
- Importing `in2lambda.katex_convert` no longer writes a file called `log` into the working directory. That module reports what it changed in an expression to the `in2lambda.katex_convert` logger, which is silent unless the application configures logging.
- `in2lambda convert` now reads a .docx that holds an image. in2lambda looks in the document for the directories a `\graphicspath` names, and read the document as UTF-8 text to find them. A .docx is a zip file, so converting a Word document holding a figure raised `UnicodeDecodeError`. in2lambda now reads a document that is not UTF-8 text as naming no directory, which is what a .docx names.
- The rest of the Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects.
diff --git a/in2lambda/filters/PartsOneSol/filter.py b/in2lambda/filters/PartsOneSol/filter.py
index efe685f..e4b4be8 100755
--- a/in2lambda/filters/PartsOneSol/filter.py
+++ b/in2lambda/filters/PartsOneSol/filter.py
@@ -49,11 +49,14 @@ def pandoc_filter(
for item in elem.content:
set.current_question.add_part_text(item)
- # Solution is in a Div with nested content being "Solution"
+ # Pandoc writes a LaTeX environment it has no block for as a Div whose classes
+ # hold the environment's name, so \begin{solution} becomes a Div classed
+ # "solution". Some documents instead write the word Solution as the first block
+ # of the Div; the filter accepts both.
case pf.Div:
- if pf.stringify(elem.content[0].content) == "Solution":
- set.current_question.add_solution(
- pf.stringify(elem) # [len("Solution") :] - For Jon Rackham
- )
+ if "solution" in elem.classes or (
+ elem.content and pf.stringify(elem.content[0].content) == "Solution"
+ ):
+ set.current_question.add_solution(pf.stringify(elem))
return None
diff --git a/in2lambda/main.py b/in2lambda/main.py
index 365d000..d235c9c 100644
--- a/in2lambda/main.py
+++ b/in2lambda/main.py
@@ -103,7 +103,7 @@ def runner(
>>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS
Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution='', answer='', response_areas=[]), ...], images=[], main_text='This is a sample question\n\n'), ...])
>>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS
- Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution='', answer='', response_areas=[]), ...], images=[...], main_text='Here is some preliminary question information that might be useful.'), ...])
+ Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution='This is the final answer...', answer='', response_areas=[]), ...], images=[...], main_text='Here is some preliminary question information that might be useful.'), ...])
"""
_require_conversion_tools()
import panflute as pf
diff --git a/tests/fixtures/against_convert/PartsOneSol/differs.txt b/tests/fixtures/against_convert/PartsOneSol/differs.txt
index e9d9fd5..c87077b 100644
--- a/tests/fixtures/against_convert/PartsOneSol/differs.txt
+++ b/tests/fixtures/against_convert/PartsOneSol/differs.txt
@@ -1,4 +1,2 @@
-Question 1 "", part (a), worked solution: the draft says 'This is the final answer. It contains the solutions for all parts without breaking them up. $$ 1+1 = 2 $$ The solution is copied across all parts.' and convert says '' # t52: the PartsOneSol filter drops every solution
Question 1 "", part (b), text: the draft says "The filter still works even if there aren't any parts" and convert says 'The filter still works even if there aren’t any parts' # smart quotes: pandoc's LaTeX reader writes ’ where its commonmark_x writer writes '
-Question 1 "", part (b), worked solution: the draft says 'This is the final answer. It contains the solutions for all parts without breaking them up. $$ 1+1 = 2 $$ The solution is copied across all parts.' and convert says '' # t52: the PartsOneSol filter drops every solution
-Question 2 "", part (a): the draft wrote this part and convert did not # t52: the PartsOneSol filter drops every solution
+Question 2 "", part (a), worked solution: the draft says "And here's the solution" and convert says 'And here’s the solution' # smart quotes: pandoc's LaTeX reader writes ’ where its commonmark_x writer writes '
diff --git a/tests/fixtures/against_convert/README.md b/tests/fixtures/against_convert/README.md
index fa04f16..22abd76 100644
--- a/tests/fixtures/against_convert/README.md
+++ b/tests/fixtures/against_convert/README.md
@@ -42,11 +42,6 @@ A folder with no such file is a document the two routes say the same thing about
Finding a difference the file does not list fails the test, and so does agreeing where it
lists one: closing a ticket below means deleting the lines it names.
-- **t52** - the `PartsOneSol` filter matches a solution environment only when the Div's
- first element stringifies to `Solution`, which pandoc writes for no solution environment,
- so convert drops every worked solution in that layout. The draft writes both solutions
- of `PartsOneSol/example.tex`, and the second question's solution is a part of its own
- that convert has nothing to match.
- **t53** - no draft command writes a `qN.pM.solution`, and no selector reaches inside the
top-level `\item` that `PartPartSolSol/example.tex` and `PartSolPartSol/example.tex`
nest each part's solution in, so those lines are marked ignore and the draft answers
diff --git a/tests/test_runner.py b/tests/test_runner.py
index c301899..4d21bf1 100644
--- a/tests/test_runner.py
+++ b/tests/test_runner.py
@@ -37,6 +37,32 @@ def test_runner_returns_populated_set(filter_name: str, filters_dir: str) -> Non
assert question.main_text or question.parts
+@pytest.mark.parametrize("filter_name", builtin_filters())
+def test_runner_exports_the_examples_solutions(
+ filter_name: str, filters_dir: str
+) -> None:
+ """A solution written in an example is a worked solution in the exported set.
+
+ Each example that writes a ``solution`` environment answers every question in it, so
+ every part carries a worked solution. An example added later that leaves a question
+ unanswered needs a test of its own rather than a looser assertion here.
+ """
+ example = _example(filters_dir, filter_name)
+ with open(example) as source:
+ written_solutions = "\\begin{solution}" in source.read()
+ if not written_solutions:
+ pytest.skip(f"{filter_name}'s example writes no solution environment")
+
+ result = runner(example, filter_name)
+
+ for number, question in enumerate(result.questions, start=1):
+ assert question.parts, f"question {number} has no parts to answer"
+ for index, part in enumerate(question.parts, start=1):
+ assert (
+ part.worked_solution.strip()
+ ), f"question {number} part {index} has no worked solution"
+
+
@pytest.mark.parametrize("filter_name", builtin_filters())
def test_runner_writes_importable_json(
filter_name: str, filters_dir: str, tmp_path