Spaces:
Sleeping
Sleeping
Upload 32 files
Browse files- CHANGELOG.md +139 -0
- LICENSE.txt +29 -0
- MANIFEST.in +2 -0
- README.md +720 -21
- app.py +77 -0
- make_gui_exe/README.md +35 -0
- make_gui_exe/make_tk_exe.bat +47 -0
- make_gui_exe/text2qti_tk.pyw +2 -0
- requirements.txt +1 -3
- setup.cfg +6 -0
- setup.py +65 -0
- temp.txt +59 -0
- temp.zip +3 -0
- test_quiz.txt +143 -0
- test_quiz.zip +3 -0
- test_quiz_error.txt +143 -0
- text2qti/__init__.py +14 -0
- text2qti/cmdline.py +173 -0
- text2qti/config.py +127 -0
- text2qti/err.py +12 -0
- text2qti/export.py +412 -0
- text2qti/fmtversion.py +217 -0
- text2qti/gui/__init__.py +0 -0
- text2qti/gui/tk.py +320 -0
- text2qti/markdown.py +560 -0
- text2qti/pymd_pandoc_attr.py +105 -0
- text2qti/qti.py +74 -0
- text2qti/quiz.py +1186 -0
- text2qti/version.py +4 -0
- text2qti/xml_assessment.py +621 -0
- text2qti/xml_assessment_meta.py +110 -0
- text2qti/xml_imsmanifest.py +87 -0
CHANGELOG.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Change Log
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
## v0.7.0 (dev)
|
| 5 |
+
|
| 6 |
+
* Numbers in scientific notation with leading zeros in the exponent are now
|
| 7 |
+
permitted for siunitx notation within Markdown. The zeros are stripped
|
| 8 |
+
during processing.
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
## v0.6.0 (2022-08-31)
|
| 13 |
+
|
| 14 |
+
* Leading plus signs `+` are now stripped for siunitx notation within
|
| 15 |
+
Markdown.
|
| 16 |
+
|
| 17 |
+
* Added `amsmath`, `amssymb`, and `siunitx` to template for LaTeX solutions
|
| 18 |
+
export.
|
| 19 |
+
|
| 20 |
+
* Fixed a bug with error handling when Pandoc fails to export solutions (#47).
|
| 21 |
+
|
| 22 |
+
* For question groups, the value of `pick` is now allowed to equal the total
|
| 23 |
+
number of questions (#44).
|
| 24 |
+
|
| 25 |
+
* Added support for Pandoc-style attributes on images:
|
| 26 |
+
`{#id .class1 .class2 width=10em height=5em}` (#41).
|
| 27 |
+
|
| 28 |
+
* Executable code blocks now use PATH to locate executables under Windows, and
|
| 29 |
+
thus now work with Python environments. Previously PATH was ignored under
|
| 30 |
+
Windows due to the implementation details of Python's `subprocess.Popen()`
|
| 31 |
+
(#34, #42).
|
| 32 |
+
|
| 33 |
+
* Added command-line options `--solutions` and `--only-solutions` to
|
| 34 |
+
command-line application. These generate solutions in Pandoc Markdown,
|
| 35 |
+
PDF, and HTML formats. Pandoc Markdown solutions are only suitable for
|
| 36 |
+
use with LaTeX and HTML (#35).
|
| 37 |
+
|
| 38 |
+
* Added quiz-level options `feedback is solution`, `solutions sample groups`,
|
| 39 |
+
and `solutions randomize groups` for customizing solutions. Added
|
| 40 |
+
group-level option `solutions pick` for customizing solutions. Added
|
| 41 |
+
question-level syntax involving `!` for providing solutions.
|
| 42 |
+
|
| 43 |
+
* In quiz parsing, simplified question type handling and error checking.
|
| 44 |
+
|
| 45 |
+
* Fixed bug in calculation of total points possible per quiz.
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
## v0.5.0 (2020-09-28)
|
| 50 |
+
|
| 51 |
+
* Added `text2qti_tk` executable, which provides a basic graphical user
|
| 52 |
+
interface (GUI) via `tkinter`. Added build scripts in `make_gui_exe/` for
|
| 53 |
+
creating a standalone GUI executable under Windows with PyInstaller (#27).
|
| 54 |
+
* In executable code blocks, `.python` now invokes `python3` on systems where
|
| 55 |
+
`python` is equivalent to `python2` as well as on systems that lack a
|
| 56 |
+
`python` executable. The README now suggests using `.python3` and
|
| 57 |
+
`.python2` to be more explicit (#22).
|
| 58 |
+
* Added a new keyword argument `executable` for executable code blocks, which
|
| 59 |
+
allows a custom executable (including path) to be specified for running
|
| 60 |
+
code. Added support for periods in code block language attributes, so that
|
| 61 |
+
things like `.python3.8` are now possible.
|
| 62 |
+
* Fixed bug caused by swapped identifiers in QTI XML (#18, #19). Now quiz
|
| 63 |
+
descriptions work with Canvas and question titles appear in Canvas in the
|
| 64 |
+
quiz editor (but not in the student view). The following options now work
|
| 65 |
+
with Canvas: `Shuffle answers`, `Show correct answers`,
|
| 66 |
+
`One question at a time`, and `Can't go back`.
|
| 67 |
+
* Installation (`setup.py`) now requires `markdown` >= 3.2 to ensure
|
| 68 |
+
compatibility and avoid Markdown parsing bugs fixed in 3.2 (#31).
|
| 69 |
+
* Fixed bug that produced incorrect QTI output paths when using quiz files
|
| 70 |
+
outside the current working directory (#28, #29).
|
| 71 |
+
* README now covers more options for installing the development version (#20).
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
## v0.4.0 (2020-07-17)
|
| 76 |
+
|
| 77 |
+
* Improved preprocessing for siunitx notation, LaTeX math, and HTML comments.
|
| 78 |
+
Fixed catastrophic backtracking in LaTeX math regex (#11). Added support
|
| 79 |
+
for newlines in HTML comments. The preprocessor now skips backslash
|
| 80 |
+
escapes, inline code containing newlines, and fenced code blocks (as long as
|
| 81 |
+
they do not start on the same line as a list marker). The preprocessor now
|
| 82 |
+
handles the escape `\$` itself, since Python-Markdown ignores it (#14).
|
| 83 |
+
* Python-Markdown's Markdown-in-HTML extension is now enabled (#13).
|
| 84 |
+
* Added quiz options `Shuffle answers`, `Show correct answers`,
|
| 85 |
+
`One question at a time`, and `Can't go back` (#10). These options are
|
| 86 |
+
apparently ignored by Canvas, but may work with some other systems.
|
| 87 |
+
* Revised README to clarify that some features are apparently not supported
|
| 88 |
+
by Canvas (#16).
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
## v0.3.0 (2020-05-26)
|
| 93 |
+
|
| 94 |
+
* Added support for multiple-answers questions.
|
| 95 |
+
* Added support for short-answer (fill-in-the-blank) questions.
|
| 96 |
+
* Added support for file-upload questions.
|
| 97 |
+
* Added support for setting question titles and point values (#9).
|
| 98 |
+
* Added support for text regions outside questions.
|
| 99 |
+
* Added `--pandoc-mathml` command-line option. This converts LaTeX to MathML
|
| 100 |
+
via Pandoc, rather than using a Canvas LaTeX rendering URL (#4).
|
| 101 |
+
* Added support for comments at the top level of quiz files (outside Markdown
|
| 102 |
+
content like questions, choices, or feedback). HTML comments within
|
| 103 |
+
Markdown are now stripped and no longer appear in the final QTI file (#2).
|
| 104 |
+
* For numerical questions, exact answers with allowed margin are now treated
|
| 105 |
+
as exact answers, rather than being converted into ranges of values. This
|
| 106 |
+
primarily affects how feedback for incorrect answers is worded (#7).
|
| 107 |
+
* Essay questions now support general feedback.
|
| 108 |
+
* Fixed a bug that prevented incorrect question feedback from working.
|
| 109 |
+
* Relaxed indentation requirements for quiz titles spanning multiple lines.
|
| 110 |
+
Indentation for wrapped lines must now be at least 2 spaces or 1 tab, rather
|
| 111 |
+
than being equivalent to that of the first character in the title.
|
| 112 |
+
* Fixed a bug that allowed trailing whitespace to cause incorrect indentation
|
| 113 |
+
calculations, resulting in indentation errors in valid quizzes.
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
## v0.2.0 (2020-04-23)
|
| 118 |
+
|
| 119 |
+
* Added support for images, using standard Markdown syntax.
|
| 120 |
+
* Added support for multi-paragraph descriptions, questions, choices, and
|
| 121 |
+
feedback.
|
| 122 |
+
* Added support for essay questions.
|
| 123 |
+
* Added support for numerical questions.
|
| 124 |
+
* Added support for question groups. A subset of the questions in a group is
|
| 125 |
+
chosen at random when a quiz is taken.
|
| 126 |
+
* Added support for executable code blocks. These can be used to generate
|
| 127 |
+
questions automatically. This feature requires the command-line flag
|
| 128 |
+
`--run-code-blocks` or setting `run_code_blocks = true` in the config
|
| 129 |
+
file.
|
| 130 |
+
* Quiz titles are now processed as plain text, rather than as Markdown,
|
| 131 |
+
because QTI does not support HTML titles.
|
| 132 |
+
* Fixed a bug that prevented source file names from appearing in error
|
| 133 |
+
messages. Fixed misspelling of "imsmanifest.xml" in QTI output.
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
## v0.1.0 (2020-04-01)
|
| 138 |
+
|
| 139 |
+
* Initial release.
|
LICENSE.txt
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BSD 3-Clause License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
All rights reserved.
|
| 5 |
+
|
| 6 |
+
Redistribution and use in source and binary forms, with or without
|
| 7 |
+
modification, are permitted provided that the following conditions are met:
|
| 8 |
+
|
| 9 |
+
* Redistributions of source code must retain the above copyright notice, this
|
| 10 |
+
list of conditions and the following disclaimer.
|
| 11 |
+
|
| 12 |
+
* Redistributions in binary form must reproduce the above copyright notice,
|
| 13 |
+
this list of conditions and the following disclaimer in the documentation
|
| 14 |
+
and/or other materials provided with the distribution.
|
| 15 |
+
|
| 16 |
+
* Neither the name of the copyright holder nor the names of its
|
| 17 |
+
contributors may be used to endorse or promote products derived from
|
| 18 |
+
this software without specific prior written permission.
|
| 19 |
+
|
| 20 |
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
| 21 |
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
| 22 |
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
| 23 |
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
| 24 |
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
| 25 |
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
| 26 |
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
| 27 |
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
| 28 |
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
| 29 |
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
MANIFEST.in
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
include setup.cfg setup.py README.md LICENSE.txt CHANGELOG.md
|
| 2 |
+
recursive-include text2qti *.py
|
README.md
CHANGED
|
@@ -1,21 +1,720 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# text2qti – Create quizzes in QTI format from Markdown-based plain text
|
| 2 |
+
|
| 3 |
+
text2qti converts
|
| 4 |
+
[Markdown](https://daringfireball.net/projects/markdown/)-based plain text
|
| 5 |
+
files into quizzes in QTI format (version 1.2), which can be imported by
|
| 6 |
+
[Canvas](https://www.instructure.com/canvas/) and other educational software.
|
| 7 |
+
It supports multiple-choice, true/false, multiple-answers, numerical,
|
| 8 |
+
short-answer (fill-in-the-blank), essay, and file-upload questions. It
|
| 9 |
+
includes basic support for LaTeX math within Markdown, and allows a limited
|
| 10 |
+
subset of [siunitx](https://ctan.org/pkg/siunitx) notation for units and for
|
| 11 |
+
numbers in scientific notation.
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
## Examples
|
| 16 |
+
|
| 17 |
+
text2qti allows quick and efficient quiz creation. Example
|
| 18 |
+
**multiple-choice** plain-text quiz question that can be converted to QTI and
|
| 19 |
+
then imported by Canvas:
|
| 20 |
+
|
| 21 |
+
```
|
| 22 |
+
1. What is 2+3?
|
| 23 |
+
a) 6
|
| 24 |
+
b) 1
|
| 25 |
+
*c) 5
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
A **question** is created by a line that starts with a number followed by a
|
| 29 |
+
period and one or more spaces or tabs ("`1. `"). Possible **choices** are
|
| 30 |
+
created by lines that start with a letter followed by a closing parenthesis
|
| 31 |
+
and one or more spaces or tabs ("`a) `"). Numbers and letters do not have to
|
| 32 |
+
be ordered or unique. The **correct** choice is designated with an asterisk
|
| 33 |
+
("`*c) `"). All question and choice text is processed as
|
| 34 |
+
[Markdown](https://daringfireball.net/projects/markdown/).
|
| 35 |
+
|
| 36 |
+
There is also support for a quiz title and description, as well as question
|
| 37 |
+
titles, point values, and feedback. Note that unlike most other text, titles
|
| 38 |
+
like quiz and question titles are treated as plain text, not Markdown, due to
|
| 39 |
+
the QTI format. **Also note that Canvas correctly shows question titles
|
| 40 |
+
within its quiz editor for instructors, but always replaces them with titles
|
| 41 |
+
like "Question 1" in the student view.** Question point values must be
|
| 42 |
+
positive integers or half-integers.
|
| 43 |
+
|
| 44 |
+
```
|
| 45 |
+
Quiz title: Addition
|
| 46 |
+
Quiz description: Checking addition.
|
| 47 |
+
|
| 48 |
+
Title: An addition question
|
| 49 |
+
Points: 2
|
| 50 |
+
1. What is 2+3?
|
| 51 |
+
... General question feedback.
|
| 52 |
+
+ Feedback for correct answer.
|
| 53 |
+
- Feedback for incorrect answer.
|
| 54 |
+
a) 6
|
| 55 |
+
... Feedback for this particular answer.
|
| 56 |
+
b) 1
|
| 57 |
+
... Feedback for this particular answer.
|
| 58 |
+
*c) 5
|
| 59 |
+
... Feedback for this particular answer.
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
**Multiple-answers questions** use `[]` or `[ ]` for incorrect answers and
|
| 63 |
+
`[*]` for correct answers.
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
1. Which of the following are dinosaurs?
|
| 67 |
+
[ ] Woolly mammoth
|
| 68 |
+
[*] Tyrannosaurus rex
|
| 69 |
+
[*] Triceratops
|
| 70 |
+
[ ] Smilodon fatalis
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
**Numerical questions** use an equals sign followed by one or
|
| 74 |
+
more spaces or tabs followed by the numerical answer. Acceptable answers can
|
| 75 |
+
be designated as a range of the form `[<min>, <max>]` or as a correct answer
|
| 76 |
+
with a specified acceptable margin of error `<ans> +- <margin>`. When the
|
| 77 |
+
latter form is used, `<margin>` can be either a number or a percentage.
|
| 78 |
+
`<margin>` can be omitted when the answer is an integer and an exact answer is
|
| 79 |
+
required. In this case, scientific notation is not permitted, but the
|
| 80 |
+
underscore can be used as a digit separator; for example, `1000` and `1_000`
|
| 81 |
+
are both valid, but `1e3` is not. An exact answer can be required for
|
| 82 |
+
floating-point numbers, but this requires an explicit `+- 0`, since a range is
|
| 83 |
+
typically more appropriate for floating-point values. Numerical questions
|
| 84 |
+
have the limitation that the absolute value of the smallest acceptable answer
|
| 85 |
+
must be greater than or equal to 0.0001 (1e-4).
|
| 86 |
+
|
| 87 |
+
```
|
| 88 |
+
1. What is the square root of 2?
|
| 89 |
+
= 1.4142 +- 0.0001
|
| 90 |
+
|
| 91 |
+
2. What is the cube root of 2?
|
| 92 |
+
= [1.2598, 1.2600]
|
| 93 |
+
|
| 94 |
+
3. What is 2+3?
|
| 95 |
+
= 5
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
**Short-answer (fill-in-the-blank) questions** use an asterisk followed by one
|
| 99 |
+
or more spaces or tabs followed by an answer. Multiple acceptable answers can
|
| 100 |
+
be given. Answers are restricted to a single line each and are treated as
|
| 101 |
+
plain text, not Markdown.
|
| 102 |
+
```
|
| 103 |
+
1. Who lives at the North Pole?
|
| 104 |
+
* Santa
|
| 105 |
+
* Santa Claus
|
| 106 |
+
* Father Christmas
|
| 107 |
+
* Saint Nicholas
|
| 108 |
+
* Saint Nick
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
**Essay questions** are indicated by a sequence of three or more underscores.
|
| 112 |
+
They only support general question feedback.
|
| 113 |
+
|
| 114 |
+
```
|
| 115 |
+
1. Write an essay.
|
| 116 |
+
... General question feedback.
|
| 117 |
+
____
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
**File-upload questions** are indicated by a sequence of three or more
|
| 121 |
+
circumflex accents. They only support general question feedback.
|
| 122 |
+
```
|
| 123 |
+
1. Upload a file.
|
| 124 |
+
... General question feedback.
|
| 125 |
+
^^^^
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
**Text regions** outside of questions are supported. Note that unlike most
|
| 129 |
+
other text, titles like text region titles are treated as plain text, not
|
| 130 |
+
Markdown, due to the QTI format. Also note that Canvas apparently ignores the
|
| 131 |
+
text region title and only displays the text itself. Text regions are not
|
| 132 |
+
required to have both a title and text; either may be used alone, but the
|
| 133 |
+
title must come first when both are present.
|
| 134 |
+
```
|
| 135 |
+
Text title: Instructions about the next questions
|
| 136 |
+
Text: General comments about the next questions.
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
## Installation
|
| 141 |
+
|
| 142 |
+
Install **Python 3.6+** if it is not already available on your machine. See
|
| 143 |
+
https://www.python.org/, or use the package manager or app store for your
|
| 144 |
+
operating system. Depending on your use case, you may want to consider a
|
| 145 |
+
Python distribution like [Anaconda](https://www.anaconda.com/distribution/)
|
| 146 |
+
instead.
|
| 147 |
+
|
| 148 |
+
Install
|
| 149 |
+
[setuptools](https://packaging.python.org/tutorials/installing-packages/)
|
| 150 |
+
for Python if it is not already installed. This can be accomplished by
|
| 151 |
+
running
|
| 152 |
+
```
|
| 153 |
+
python -m pip install setuptools
|
| 154 |
+
```
|
| 155 |
+
on the command line. Depending on your system, you may need to use `python3`
|
| 156 |
+
instead of `python`. This will often be the case for Linux and OS X.
|
| 157 |
+
|
| 158 |
+
Install text2qti by running this on the command line:
|
| 159 |
+
```
|
| 160 |
+
python -m pip install text2qti
|
| 161 |
+
```
|
| 162 |
+
Depending on your system, you may need to use `python3` instead of `python`.
|
| 163 |
+
This will often be the case for Linux and OS X.
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
### Upgrading
|
| 167 |
+
|
| 168 |
+
```
|
| 169 |
+
python -m pip install text2qti --upgrade
|
| 170 |
+
```
|
| 171 |
+
Depending on your system, you may need to use `python3` instead of `python`.
|
| 172 |
+
This will often be the case for Linux and OS X.
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
### Installing the development version
|
| 176 |
+
|
| 177 |
+
If you want to install the development version to use the latest features,
|
| 178 |
+
download `text2qti` from [GitHub](https://github.com/gpoore/text2qti) and
|
| 179 |
+
extract the files. A few different ways to install the development version
|
| 180 |
+
are listed below. Depending on your system, you may need to use `python3`
|
| 181 |
+
instead of `python` in the commands below. This will often be the case for
|
| 182 |
+
Linux and OS X.
|
| 183 |
+
|
| 184 |
+
* You can install using the included `setup.py` by running
|
| 185 |
+
```
|
| 186 |
+
python setup.py install
|
| 187 |
+
```
|
| 188 |
+
Depending on your system configuration, especially if you do not have root
|
| 189 |
+
or administrator privileges, you may want to
|
| 190 |
+
[customize the installation location](https://docs.python.org/3.8/install/#alternate-installation-the-user-scheme).
|
| 191 |
+
For example, you can add `--user` to install under `%APPDATA%\Python` (Windows), `~/.local` (UNIX, and Mac OS X non-framework builds), or
|
| 192 |
+
`~/Library/Python/<VERSION>` (Mac framework builds):
|
| 193 |
+
```
|
| 194 |
+
python setup.py install --user
|
| 195 |
+
```
|
| 196 |
+
* You can install using `pip`. For example, in the directory with `setup.py`,
|
| 197 |
+
run this:
|
| 198 |
+
```
|
| 199 |
+
python -m pip install .
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
## Usage
|
| 206 |
+
|
| 207 |
+
text2qti has been designed to create QTI files for use with
|
| 208 |
+
[Canvas](https://www.instructure.com/canvas/). Some features may not be
|
| 209 |
+
supported by other educational software. You should **always preview**
|
| 210 |
+
quizzes or assessments after converting them to QTI and importing them.
|
| 211 |
+
|
| 212 |
+
Write your quiz or assessment in a plain text file. You can use a basic
|
| 213 |
+
editor like Notepad or gedit, or a code editor like
|
| 214 |
+
[VS Code](https://code.visualstudio.com/). You can even use Microsoft Word,
|
| 215 |
+
as long as you save your file as plain text (*.txt).
|
| 216 |
+
|
| 217 |
+
text2qti includes a graphical application and a command-line application.
|
| 218 |
+
|
| 219 |
+
* To use the graphical application, open a command line and run `text2qti_tk`.
|
| 220 |
+
|
| 221 |
+
* To use the command-line application, open a command line in the same folder
|
| 222 |
+
or directory as your quiz file. Under Windows, you can hold the SHIFT
|
| 223 |
+
button down on the keyboard, then right click next to your file, and select
|
| 224 |
+
"Open PowerShell window here" or "Open command window here". You can also
|
| 225 |
+
launch "Command Prompt" or "PowerShell" through the Start menu, and then
|
| 226 |
+
navigate to your file using `cd`.
|
| 227 |
+
|
| 228 |
+
Run the `text2qti` application using a command like this:
|
| 229 |
+
```
|
| 230 |
+
text2qti quiz.txt
|
| 231 |
+
```
|
| 232 |
+
Replace "quiz.txt" with the name of your file. This will create a file like
|
| 233 |
+
`quiz.zip` (with "quiz" replaced by the name of your file) which is the
|
| 234 |
+
converted quiz in QTI format.
|
| 235 |
+
|
| 236 |
+
Instructions for using the QTI file with Canvas:
|
| 237 |
+
* Go to the course in which you want to use the quiz.
|
| 238 |
+
* Go to Settings, click on "Import Course Content", select "QTI .zip file",
|
| 239 |
+
choose your file, and click "Import". Typically you should not need to
|
| 240 |
+
select a question bank; that should be managed automatically.
|
| 241 |
+
* While the quiz upload will often be very fast, there is an additional
|
| 242 |
+
processing step that can take up to several minutes. The status will
|
| 243 |
+
probably appear under "Current Jobs" after upload.
|
| 244 |
+
* Once the quiz import is marked as "Completed", the imported quiz should be
|
| 245 |
+
available under Quizzes. If the imported quiz does not appear after
|
| 246 |
+
several minutes, there may be an error in your quiz file or a bug in
|
| 247 |
+
`text2qti`. When Canvas encounters an invalid quiz file, it tends to fail
|
| 248 |
+
silently; instead of reporting an error in the quiz file, it just never
|
| 249 |
+
creates a quiz based on the invalid file.
|
| 250 |
+
* You should **always preview the quiz before use**. text2qti can detect a
|
| 251 |
+
number of potential issues, but not everything.
|
| 252 |
+
|
| 253 |
+
Typically, you should start your quizzes with a title, like this:
|
| 254 |
+
```
|
| 255 |
+
Quiz title: Title here
|
| 256 |
+
```
|
| 257 |
+
Otherwise, all quizzes will have the default title "Quiz", so it will be
|
| 258 |
+
difficult to tell them apart. Another option is to rename quizzes after
|
| 259 |
+
importing them. Note that unlike most other text, the title is treated as
|
| 260 |
+
plain text, not Markdown, due to the QTI format.
|
| 261 |
+
|
| 262 |
+
When you run `text2qti` for the first time, it will attempt to create a
|
| 263 |
+
configuration file called `.text2qti.bespon` in your home or user directory.
|
| 264 |
+
It will also ask for an institutional LaTeX rendering URL. This is only
|
| 265 |
+
needed if you plan to use LaTeX math; if not, simply press ENTER to continue.
|
| 266 |
+
* If you use Canvas, log into your account and look in the browser address
|
| 267 |
+
bar. You will typically see an address that starts with something like
|
| 268 |
+
`institution.instructure.com/` or `canvas.institution.edu/`, with
|
| 269 |
+
`institution` replaced by the name of your school or an abbreviation for
|
| 270 |
+
it. The LateX rendering URL that you want to use will then be something
|
| 271 |
+
like `https://institution.instructure.com/equation_images/` or
|
| 272 |
+
`https://canvas.institution.edu/equation_images/`, with `institution`
|
| 273 |
+
replaced by the appropriate value for your school. If the URL is like the
|
| 274 |
+
second form, you may need to replace the `.edu` domain with the appropriate
|
| 275 |
+
value for your institution.
|
| 276 |
+
* If you use other educational software that handles LaTeX in a manner
|
| 277 |
+
compatible with Canvas, consult the documentation for your software. Or
|
| 278 |
+
perhaps create a simple quiz within the software using its built-in tools,
|
| 279 |
+
then export the quiz to QTI and look through the resulting output to find
|
| 280 |
+
the URL.
|
| 281 |
+
* If you are using educational software that does not handle LaTeX in a
|
| 282 |
+
manner compatible with Canvas, try the `--pandoc-mathml` command-line
|
| 283 |
+
option when creating QTI files (note that this requires that
|
| 284 |
+
[Pandoc](https://pandoc.org/) be installed). If that does not work, please
|
| 285 |
+
open an issue requesting support for that software, and include as much
|
| 286 |
+
information as possible about how that software processes LaTeX.
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
## Additional features
|
| 291 |
+
|
| 292 |
+
### Question groups
|
| 293 |
+
|
| 294 |
+
A question group contains multiple questions, and only a specified number of
|
| 295 |
+
these are randomly selected and used each time a quiz is taken.
|
| 296 |
+
|
| 297 |
+
```
|
| 298 |
+
GROUP
|
| 299 |
+
pick: 1
|
| 300 |
+
points per question: 1
|
| 301 |
+
|
| 302 |
+
1. A question.
|
| 303 |
+
*a) true
|
| 304 |
+
b) false
|
| 305 |
+
|
| 306 |
+
2. Another question.
|
| 307 |
+
*a) true
|
| 308 |
+
b) false
|
| 309 |
+
|
| 310 |
+
END_GROUP
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
The number of questions from the group that are used is specified with
|
| 314 |
+
"`pick:`". If this is omitted, it defaults to `1`. The points assigned per
|
| 315 |
+
question is specified with "`points per question:`". If this is omitted, it
|
| 316 |
+
defaults to `1`. All questions within a group must be worth the same number
|
| 317 |
+
of points.
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
### Executable code blocks
|
| 321 |
+
|
| 322 |
+
text2qti can execute the code in Markdown-style fenced code blocks. Code can
|
| 323 |
+
be used to generate questions within a quiz. Everything written to stdout by
|
| 324 |
+
the executed code is included in the quiz file; the code block is replaced by
|
| 325 |
+
stdout.
|
| 326 |
+
|
| 327 |
+
``````
|
| 328 |
+
```{.python .run}
|
| 329 |
+
import textwrap
|
| 330 |
+
for x in [2, 3]:
|
| 331 |
+
print(textwrap.dedent(rf"""
|
| 332 |
+
1. What is ${x}\times 5$?
|
| 333 |
+
*a) ${x*5}$
|
| 334 |
+
b) ${x+1}$
|
| 335 |
+
"""))
|
| 336 |
+
```
|
| 337 |
+
``````
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
For code to be executed, there are a few requirements:
|
| 341 |
+
|
| 342 |
+
* The code block fences (` ``` `) must not be indented; the code block must be
|
| 343 |
+
at the top level of the document, not part of a question, choice, or
|
| 344 |
+
feedback.
|
| 345 |
+
|
| 346 |
+
* As a security measure, code execution is disabled by default, so executable
|
| 347 |
+
code blocks will trigger an error. Run `text2qti` with the option
|
| 348 |
+
`--run-code-blocks` to enable code execution, or set `run_code_blocks =
|
| 349 |
+
true` in the text2qti config file in your user or home directory.
|
| 350 |
+
|
| 351 |
+
* The text immediately after the opening fence must have the form
|
| 352 |
+
`{.lang .run}` or `{.lang .run executable=<executable>}`. This is inspired
|
| 353 |
+
by the code-block attributes in
|
| 354 |
+
[Pandoc Markdown](https://pandoc.org/MANUAL.html).
|
| 355 |
+
|
| 356 |
+
If the keyword argument `executable` is not provided, then `lang` must
|
| 357 |
+
designate an executable that can run the code once the code has been saved
|
| 358 |
+
to a file. In the example above, `python` is extracted from the first line
|
| 359 |
+
(` ```{.python .run}`), code is saved in a temporary file, and then the
|
| 360 |
+
file is executed via `python <file>`.
|
| 361 |
+
|
| 362 |
+
If `executable` is used to specify an executable, then `lang` is ignored by
|
| 363 |
+
`text2qti`, but it is still useful since some editors will use it to provide
|
| 364 |
+
syntax highlighting.
|
| 365 |
+
|
| 366 |
+
When `executable` is specified, the executable path must be quoted with
|
| 367 |
+
double quotes `"` if it contains anything other than the tilde, Unicode word
|
| 368 |
+
characters, numbers, forward slashes, periods, hyphens, and underscores.
|
| 369 |
+
When the executable path is quoted, backslashes and quotation marks are
|
| 370 |
+
still prohibited; forward slashes should be used under all operating systems
|
| 371 |
+
including Windows. A leading `~` in the executable path is expanded to the
|
| 372 |
+
user's home directory under all operating systems including Windows.
|
| 373 |
+
|
| 374 |
+
* **Special Python note**: When `.python` is used with an executable code
|
| 375 |
+
block without specifying an `executable`, code will run with `python3` if
|
| 376 |
+
either of these conditions is met:
|
| 377 |
+
|
| 378 |
+
- `python3` exists on the system and `python` is equivalent to `python2`.
|
| 379 |
+
|
| 380 |
+
- `python` does not exist on the system, but `python3` does exist.
|
| 381 |
+
|
| 382 |
+
To avoid ambiguity, you may want to use `.python3` and `.python2` rather
|
| 383 |
+
than `.python` when working with operating systems other than Windows, or
|
| 384 |
+
when working with a Windows installation that includes a `python3`
|
| 385 |
+
executable or symlink. It is also possible to be even more specific by
|
| 386 |
+
using something like `.python3.8`.
|
| 387 |
+
|
| 388 |
+
Each code block is executed in its own process, so data and variables are not
|
| 389 |
+
shared between code blocks.
|
| 390 |
+
|
| 391 |
+
If an executable code block generates multiple questions that are identical,
|
| 392 |
+
or multiple choices for a single question that are identical, this will be
|
| 393 |
+
detected by `text2qti` and an error will be reported. Questions or choices
|
| 394 |
+
that may be equivalent, but are not represented by exactly the same text,
|
| 395 |
+
cannot be detected (for example, things like `100` versus `1e2`, or `answer`
|
| 396 |
+
versus `Answer`).
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
### Additional quiz options
|
| 400 |
+
|
| 401 |
+
There are additional quiz options that can be set immediately after the quiz
|
| 402 |
+
title and quiz description. These all take values `true` or `false`. For
|
| 403 |
+
example, `shuffle answers: true` could be on the line right after the quiz
|
| 404 |
+
description.
|
| 405 |
+
|
| 406 |
+
* `shuffle answers` — Shuffle answer order for questions.
|
| 407 |
+
* `show correct answers` — Show correct answers after submission.
|
| 408 |
+
* `one question at a time` — Only show one question at a time.
|
| 409 |
+
* `can't go back` — Don't allow going back to the previous question when in
|
| 410 |
+
`one question at a time` mode.
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
## Details for writing quiz text
|
| 416 |
+
|
| 417 |
+
text2qti processes almost all text as
|
| 418 |
+
[Markdown](https://daringfireball.net/projects/markdown/), using
|
| 419 |
+
[Python-Markdown](https://python-markdown.github.io/). (The only exceptions
|
| 420 |
+
are the quiz title, question titles, and text region titles, which are
|
| 421 |
+
processed as plain text due to the QTI format, plus the acceptable answers
|
| 422 |
+
for short-answer questions.) For example, `*emphasized*` produces *emphasized*
|
| 423 |
+
text, which typically appears as italics. Text can be styled using Markdown
|
| 424 |
+
notation, or with HTML. Remember to preview quizzes after conversion to QTI,
|
| 425 |
+
especially when using any significant amount of HTML.
|
| 426 |
+
|
| 427 |
+
Python-Markdown provides several
|
| 428 |
+
[extensions to basic Markdown](https://python-markdown.github.io/extensions/).
|
| 429 |
+
Currently, the following extensions are enabled:
|
| 430 |
+
* `smarty`: Automatic curly quotation marks and dashes.
|
| 431 |
+
* `sane_lists`: List behavior is closer to what might be expected.
|
| 432 |
+
* `def_list`: Definition lists of this form:
|
| 433 |
+
```
|
| 434 |
+
term
|
| 435 |
+
: definition
|
| 436 |
+
* `fenced_code`: Fenced code blocks (` ``` ` or `~~~`).
|
| 437 |
+
* `footnotes`: Footnotes using this form:
|
| 438 |
+
```
|
| 439 |
+
Normal text [^1].
|
| 440 |
+
|
| 441 |
+
[^1]: Footnote text.
|
| 442 |
+
* `tables`: Tables of this form:
|
| 443 |
+
```
|
| 444 |
+
Header | Header
|
| 445 |
+
------ | ------
|
| 446 |
+
Cell | Cell
|
| 447 |
+
Cell | Cell
|
| 448 |
+
```
|
| 449 |
+
* `md_in_html`: Text inside HTML tags is treated as Markdown. This requires
|
| 450 |
+
setting the attribute `markdown="1"` in the opening tag for block-level
|
| 451 |
+
elements. See the
|
| 452 |
+
[documentation](https://python-markdown.github.io/extensions/md_in_html/)
|
| 453 |
+
for more details about proper usage and potential issues.
|
| 454 |
+
|
| 455 |
+
While indented Markdown code blocks are supported, fenced code blocks should
|
| 456 |
+
be preferred. Indented code can interfere with the preprocessor that strips
|
| 457 |
+
HTML comments and handles LaTeX math and siunitx notation.
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
### Titles
|
| 461 |
+
|
| 462 |
+
Quiz, question, and text region titles are limited to a single paragraph. If
|
| 463 |
+
this paragraph is wrapped over multiple lines, all lines after the first must
|
| 464 |
+
be indented by at least two spaces or one tab, and share the same indentation.
|
| 465 |
+
All tabs are expanded to 4 spaces before indentation is compared, following
|
| 466 |
+
the typical Markdown approach.
|
| 467 |
+
|
| 468 |
+
All titles are treated as plain text, not Markdown, due to the QTI format.
|
| 469 |
+
|
| 470 |
+
Titles are always optional, but when they are used for a given element, they
|
| 471 |
+
are always required to be first, before any other attributes.
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
### Descriptions, questions, choices, feedback, and text regions
|
| 475 |
+
|
| 476 |
+
Descriptions, questions, choices, feedback, and text regions may span multiple
|
| 477 |
+
paragraphs and include arbitrary Markdown content like code blocks or
|
| 478 |
+
quotations. Everything must be indented to at least the same level as the
|
| 479 |
+
start of the first paragraph on the initial line. All tabs are expanded to 4
|
| 480 |
+
spaces before indentation is compared, following the typical Markdown
|
| 481 |
+
approach. For example,
|
| 482 |
+
```
|
| 483 |
+
1. A question paragraph that is long enough to wrap onto a second line.
|
| 484 |
+
The second line must be indented to match up with the start of the
|
| 485 |
+
paragraph text on the first line.
|
| 486 |
+
|
| 487 |
+
Another paragraph.
|
| 488 |
+
```
|
| 489 |
+
Note that the acceptable answers for short-answer questions are treated as
|
| 490 |
+
plain text and limited to a single line, and numerical answers are also
|
| 491 |
+
processed specially and limited to a single line.
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
### Images
|
| 495 |
+
|
| 496 |
+
Images are included with the standard Markdown syntax:
|
| 497 |
+
```
|
| 498 |
+

|
| 499 |
+
```
|
| 500 |
+
It will typically be easiest to put your image files in the same folder or
|
| 501 |
+
directory as the quiz file, so you can use something like ``.
|
| 502 |
+
However, file paths are supported, including `~` user expansion under all
|
| 503 |
+
operating systems. All image paths not starting with `http://` or `https://`
|
| 504 |
+
are assumed to refer to local image files (files on your machine), and will
|
| 505 |
+
result in errors if these files are not found.
|
| 506 |
+
|
| 507 |
+
[Pandoc-style attributes](https://pandoc.org/MANUAL.html#images) can be used
|
| 508 |
+
with images:
|
| 509 |
+
```
|
| 510 |
+
{#id .class1 .class2 width=10em height=5em}
|
| 511 |
+
```
|
| 512 |
+
This allows image id, classes, and dimensions to be specified without
|
| 513 |
+
resorting to HTML.
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
### LaTeX
|
| 517 |
+
|
| 518 |
+
By default, text2qti supports LaTeX using a Canvas LaTeX rendering URL. This
|
| 519 |
+
can be set during installation, or by editing the configuration file
|
| 520 |
+
`.text2qti.bespon` in your home or user directory. It is possible to convert
|
| 521 |
+
LaTeX to MathML instead with the `--pandoc-mathml` command-line option. This
|
| 522 |
+
requires that [Pandoc](https://pandoc.org/) be installed for converting LaTeX
|
| 523 |
+
to MathML. For example, to create a quiz you might run a command like this:
|
| 524 |
+
```
|
| 525 |
+
text2qti --pandoc-mathml quiz.txt
|
| 526 |
+
```
|
| 527 |
+
When `--pandoc-mathml` is used, a cache file `_text2qti_cache.zip` will be
|
| 528 |
+
created in the quiz file directory. This is used to store Pandoc MathML
|
| 529 |
+
output to increase performance for long quizzes with lots of math.
|
| 530 |
+
|
| 531 |
+
text2qti supports inline LaTeX math within dollar signs `$`. There must be a
|
| 532 |
+
non-space character immediately after the opening `$` and immediately before
|
| 533 |
+
the closing `$`. For example, `$F = ma$`. LaTeX math is limited to what is
|
| 534 |
+
supported by Canvas or whatever other educational software you are using. It
|
| 535 |
+
is usually a good idea to preview imported quizzes before assigning them,
|
| 536 |
+
because text2qti cannot detect LaTeX incompatibilities or limitations. There
|
| 537 |
+
is currently not support for block LaTeX math; only inline math is supported.
|
| 538 |
+
|
| 539 |
+
When using Canvas with LaTeX math, be aware that in some cases Canvas's
|
| 540 |
+
vertical alignment of math leaves much to be desired. Sometimes this can be
|
| 541 |
+
improved by including `\vphantom{fg}` or `\strut` at the beginning of an
|
| 542 |
+
equation. An alternative is simply to use LaTeX for all question or choice
|
| 543 |
+
text (via `\text`, etc.).
|
| 544 |
+
|
| 545 |
+
text2tqi supports a limited subset of LaTeX
|
| 546 |
+
[siunitx](https://ctan.org/pkg/siunitx) notation. You can use notation like
|
| 547 |
+
`\num{1.23e5}` to enter numbers in scientific notation. This would result in
|
| 548 |
+
`1.23×10⁵`. You can use notation like `\si{m/s}` or `\si{N.m}` to enter
|
| 549 |
+
units. These would result in `m/s` and `N·m`. Unit macros currently are not
|
| 550 |
+
supported, with these exceptions: `\degree`, `\celsius`, `\fahrenheit`,
|
| 551 |
+
`\ohm`, `\micro`. Finally, numbers and units can be combined with notation
|
| 552 |
+
like `\SI{1.23e5}{m/s}`. All of these can be used inside or outside LaTeX
|
| 553 |
+
math.
|
| 554 |
+
|
| 555 |
+
Technical note: LaTeX and siunitx support are currently implemented as
|
| 556 |
+
preprocessors that are used separately from Python-Markdown. In rare cases,
|
| 557 |
+
this may lead to conflicts with Markdown syntax. These features may be
|
| 558 |
+
reimplemented as Python-Markdown extensions in the future.
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
### Comments
|
| 562 |
+
|
| 563 |
+
There are multiple ways to add comments within a quiz file. In all cases,
|
| 564 |
+
comments are completely removed during quiz creation and do not appear in the
|
| 565 |
+
final quiz in any form.
|
| 566 |
+
|
| 567 |
+
At the top level of a quiz document (outside of questions, choices, or
|
| 568 |
+
feedback) there are two types of comments. These comments cannot be indented.
|
| 569 |
+
* Line comments: Any line that starts with a percent sign `%` is discarded.
|
| 570 |
+
* Multiline comments: If a line starts with `COMMENT`, that line and all
|
| 571 |
+
subsequent lines are discarded through a line that starts with
|
| 572 |
+
`END_COMMENT`. The `COMMENT` and `END_COMMENT` delimiters must be on lines
|
| 573 |
+
by themselves; otherwise, an error is raised.
|
| 574 |
+
|
| 575 |
+
Within Markdown text, standard HTML comments of the form `<!--comment-->` may
|
| 576 |
+
be used. These are stripped out during processing and do not appear in the
|
| 577 |
+
final QTI file. HTML comments are not supported within LaTeX math.
|
| 578 |
+
|
| 579 |
+
Technical note: HTML comments are currently stripped in a preprocessing step
|
| 580 |
+
separate from Python-Markdown. In rare cases, this may conflict with raw HTML
|
| 581 |
+
embedded in Markdown. This feature may be reimplemented as a Python-Markdown
|
| 582 |
+
extension in the future.
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
## Export solutions to PDF and HTML
|
| 587 |
+
|
| 588 |
+
There is basic support for exporting quiz solutions in Pandoc Markdown, PDF,
|
| 589 |
+
and HTML formats. This is currently only available in the command-line
|
| 590 |
+
application. Solutions exported as Pandoc Markdown are only suitable for use
|
| 591 |
+
with LaTeX and HTML. Exporting solutions as PDF requires
|
| 592 |
+
[Pandoc](https://pandoc.org/) and a LaTeX installation (such as [TeX
|
| 593 |
+
Live](https://www.tug.org/texlive/) or [MiKTeX](https://miktex.org/)). There
|
| 594 |
+
is currently no built-in support for customizing export, although you can edit
|
| 595 |
+
the Pandoc Markdown output before processing it via Pandoc.
|
| 596 |
+
|
| 597 |
+
To save solutions and also create a QTI file, use
|
| 598 |
+
`--solutions <solutions_file>`. To save solutions without creating a QTI
|
| 599 |
+
file, use `--only-solutions <solutions_file>`. `<solutions_file>` must have
|
| 600 |
+
an `.md` or `.markdown` extension for Pandoc Markdown export, `.pdf` for PDF
|
| 601 |
+
export, or `.html` for HTML export. `--solutions` and `--only-solutions` can
|
| 602 |
+
be used multiple times to generate solutions in multiple formats.
|
| 603 |
+
|
| 604 |
+
When using `--only-solutions`, be aware that solutions and QTI may differ if
|
| 605 |
+
executable code blocks generate problems using random numbers. Consider
|
| 606 |
+
creating solutions and QTI at the same time (`--solutions`), or setting a seed
|
| 607 |
+
for the random number generator so it is reproducible.
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
### Customizing questions for solutions
|
| 611 |
+
|
| 612 |
+
Each question can provide a solution or other important information that is
|
| 613 |
+
*only* included in the solutions, *never* in the QTI. This is particularly
|
| 614 |
+
useful for essay and upload questions, since they are defined without
|
| 615 |
+
specifying an answer and require manual grading. For example,
|
| 616 |
+
```
|
| 617 |
+
1. Write an essay about text2qti.
|
| 618 |
+
|
| 619 |
+
! This is important information about what the essay should cover.
|
| 620 |
+
|
| 621 |
+
This will only appear in the solutions, and can be as long or short as
|
| 622 |
+
you wish.
|
| 623 |
+
|
| 624 |
+
____
|
| 625 |
+
```
|
| 626 |
+
The syntax for a question solution is the same as that for question feedback,
|
| 627 |
+
except that an exclamation point `!` is used instead of `...` or `+` or `-`.
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
### Quiz options
|
| 631 |
+
|
| 632 |
+
At the beginning of a quiz, there are some quiz-level options that can be set
|
| 633 |
+
to customize solutions. These all take `true`/`false` values, and are `false`
|
| 634 |
+
by default. For example, add `solutions sample groups: true` at the beginning
|
| 635 |
+
of a quiz.
|
| 636 |
+
|
| 637 |
+
* `feedback is solution` — This disables the special question solution syntax
|
| 638 |
+
involving `!`, and treats general question feedback (`...`) as both feedback
|
| 639 |
+
and solution. This is useful when you want to give students solution
|
| 640 |
+
information as part of the QTI feedback and also include this same
|
| 641 |
+
information in solutions.
|
| 642 |
+
|
| 643 |
+
* `solutions sample groups` — By default, *all* questions in a question group
|
| 644 |
+
are included in solutions, with a notice about the number that are randomly
|
| 645 |
+
selected when the quiz is taken, unless the question group has `solutions
|
| 646 |
+
pick` set to use a different value.
|
| 647 |
+
|
| 648 |
+
This option causes only a sample of the questions in a group to be included
|
| 649 |
+
in the solutions. This option displays the first N questions in a group
|
| 650 |
+
sequentially, where N is the group `solutions pick` value if it has been
|
| 651 |
+
set, and otherwise the `pick` value if it has been set, and otherwise 1. It
|
| 652 |
+
is possible for solutions to include N random questions from a group instead
|
| 653 |
+
of the first N questions; see `solutions randomize groups`.
|
| 654 |
+
|
| 655 |
+
* `solutions randomize groups` — For each question group, randomize the order
|
| 656 |
+
in which questions are displayed in solutions. If only some questions from
|
| 657 |
+
a group are included in solutions (`solutions sample groups` is `true` or
|
| 658 |
+
`solutions pick` is set), also randomize which questions are displayed
|
| 659 |
+
instead of taking all displayed questions sequentially from the beginning of
|
| 660 |
+
the group.
|
| 661 |
+
|
| 662 |
+
Randomization is not used by default for two reasons that relate to quizzes
|
| 663 |
+
using `solutions pick` or `solutions sample groups`. First, including group
|
| 664 |
+
questions sequentially allows specially chosen, representative questions to
|
| 665 |
+
be placed at the beginning of the group so that they will appear in
|
| 666 |
+
solutions. If a group contains many questions that are generated by an
|
| 667 |
+
executable code block, a random selection might not provide a sample that is
|
| 668 |
+
representative. Second, if a quiz is used several semesters or years in a
|
| 669 |
+
row with only minor modifications, and new randomized solutions are
|
| 670 |
+
distributed each time, this means that eventually all questions would be
|
| 671 |
+
distributed in solutions, rather than only a fixed subset.
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
### Customizing groups for solutions
|
| 675 |
+
|
| 676 |
+
When a quiz is taken, the number of questions randomly selected from a
|
| 677 |
+
question group is the value of `pick` if `pick` is set for the group and
|
| 678 |
+
otherwise 1. However, by default solutions will include *all* questions from
|
| 679 |
+
a group. There are two ways to modify this.
|
| 680 |
+
|
| 681 |
+
It is possible to modify the number of questions displayed in solutions for a
|
| 682 |
+
specific group by setting `solutions pick` for the group. This causes only
|
| 683 |
+
the specified number of questions from the group to be displayed in solutions.
|
| 684 |
+
The questions that are displayed are taken sequentially from the beginning of
|
| 685 |
+
the group by default, with no randomization. For randomization, see the
|
| 686 |
+
quiz-level option `solutions randomize groups`.
|
| 687 |
+
|
| 688 |
+
It is also possible to modify the number of questions displayed in solutions
|
| 689 |
+
for *all* groups in a quiz by setting the quiz-level option `solutions sample
|
| 690 |
+
groups` to `true`. This option displays the first N questions in a group
|
| 691 |
+
sequentially, where N is the group `solutions pick` value if it has been set,
|
| 692 |
+
and otherwise the `pick` value if it has been set, and otherwise 1. It is
|
| 693 |
+
possible for solutions to include N random questions from a group instead of
|
| 694 |
+
the first N questions; see `solutions randomize groups`.
|
| 695 |
+
|
| 696 |
+
In the example below, the solutions would include 2 questions from the group,
|
| 697 |
+
even though only 1 is displayed when the quiz is taken. The first 2 questions
|
| 698 |
+
would be included in solutions, unless `solutions randomize groups: true` is
|
| 699 |
+
included at the beginning of the quiz.
|
| 700 |
+
|
| 701 |
+
```
|
| 702 |
+
GROUP
|
| 703 |
+
pick: 1
|
| 704 |
+
solutions pick: 2
|
| 705 |
+
|
| 706 |
+
1. A question.
|
| 707 |
+
*a) true
|
| 708 |
+
b) false
|
| 709 |
+
|
| 710 |
+
2. Another question.
|
| 711 |
+
*a) true
|
| 712 |
+
b) false
|
| 713 |
+
|
| 714 |
+
3. Yet another question.
|
| 715 |
+
*a) true
|
| 716 |
+
b) false
|
| 717 |
+
|
| 718 |
+
END_GROUP
|
| 719 |
+
```
|
| 720 |
+
|
app.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import text2qti
|
| 3 |
+
import traceback
|
| 4 |
+
import subprocess
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
def convert_txt_to_qti(file_path):
|
| 8 |
+
# Use subprocess to run the text2qti command
|
| 9 |
+
output_file = file_path.replace('.txt', '.zip')
|
| 10 |
+
try:
|
| 11 |
+
subprocess.run(["text2qti", file_path], check=True, capture_output=True, text=True)
|
| 12 |
+
return output_file, None
|
| 13 |
+
except subprocess.CalledProcessError as e:
|
| 14 |
+
# Capture the stderr output
|
| 15 |
+
error_message = e.stderr.strip() if e.stderr else "An error occurred without any specific error message."
|
| 16 |
+
return None, error_message
|
| 17 |
+
|
| 18 |
+
def main():
|
| 19 |
+
st.title("text2qti Converter")
|
| 20 |
+
|
| 21 |
+
st.markdown("""
|
| 22 |
+
For help or more information about how to generate/format .txt files for conversion and upload to Canvas, please visit the [following walk-through guide.](https://reutherlab.biosci.ucsd.edu/)
|
| 23 |
+
""")
|
| 24 |
+
|
| 25 |
+
uploaded_file = st.file_uploader("Upload a .txt file", type=["txt"])
|
| 26 |
+
|
| 27 |
+
if uploaded_file:
|
| 28 |
+
# Extract the filename without the extension
|
| 29 |
+
file_name = os.path.splitext(uploaded_file.name)[0]
|
| 30 |
+
|
| 31 |
+
# Save the uploaded file to a location with the same name
|
| 32 |
+
temp_file_path = f"{file_name}.txt"
|
| 33 |
+
with open(temp_file_path, "wb") as f:
|
| 34 |
+
f.write(uploaded_file.getbuffer())
|
| 35 |
+
|
| 36 |
+
st.write("File uploaded successfully!")
|
| 37 |
+
|
| 38 |
+
# Button to initiate the conversion
|
| 39 |
+
if st.button('Convert to QTI'):
|
| 40 |
+
try:
|
| 41 |
+
qti_zip_file, error = convert_txt_to_qti(temp_file_path)
|
| 42 |
+
if qti_zip_file and os.path.exists(qti_zip_file):
|
| 43 |
+
with open(qti_zip_file, "rb") as f:
|
| 44 |
+
zip_data = f.read()
|
| 45 |
+
st.download_button(f'Download {file_name}.zip', zip_data, file_name=f'{file_name}.zip', mime='application/zip')
|
| 46 |
+
else:
|
| 47 |
+
# If the conversion failed, display the error message
|
| 48 |
+
error_message = (f"""Failed to convert the file. You will see a long, confusing error. **Focus on the part that provides a line number and text from the quiz itself. it will often be near the end of the error message.**
|
| 49 |
+
---
|
| 50 |
+
**Here is an made-up example of what you should look for:**
|
| 51 |
+
In test_quiz_error.txt on line 23: Syntax error; unexpected text, or incorrect indentation for a wrapped paragraph: #a) Polar heads face outward, nonpolar tails face inward
|
| 52 |
+
This would indicate that there is an error on line 23 of the quiz. You need to change #a) to \*a) to fix the error.
|
| 53 |
+
Please visit the walkthrough linked above for more information.
|
| 54 |
+
---
|
| 55 |
+
**Your error:**
|
| 56 |
+
{error}")
|
| 57 |
+
""")
|
| 58 |
+
st.markdown(error_message)
|
| 59 |
+
except Exception as e:
|
| 60 |
+
# Catch any other exception that might occur and display it using traceback
|
| 61 |
+
error_traceback = traceback.format_exc()
|
| 62 |
+
st.error(f"An unexpected error occurred: {error_traceback}")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Footer
|
| 66 |
+
st.markdown("---")
|
| 67 |
+
st.markdown("""
|
| 68 |
+
The text2qti python library is Copyright (c) 2020 by Geoffrey M. Poore.
|
| 69 |
+
It can be found at [https://github.com/gpoore/text2qti](https://github.com/gpoore/text2qti)
|
| 70 |
+
and is distributed under the BSD 3-Clause License.
|
| 71 |
+
""")
|
| 72 |
+
st.markdown("""
|
| 73 |
+
This app is managed by Keefe Reuther - [https://reutherlab.biosci.ucsd.edu/](https://reutherlab.biosci.ucsd.edu/)
|
| 74 |
+
""")
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
main()
|
make_gui_exe/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Create Standalone GUI Executable for Windows
|
| 2 |
+
|
| 3 |
+
This directory contains scripts for creating a standalone GUI executable under
|
| 4 |
+
Windows with PyInstaller.
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
## Requirements
|
| 9 |
+
|
| 10 |
+
* Windows
|
| 11 |
+
* [conda](https://docs.conda.io/projects/conda/en/latest/user-guide/install/)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
## Directions
|
| 16 |
+
|
| 17 |
+
If you do not already have a local copy of the text2qti source, download
|
| 18 |
+
`make_tk_exe.bat` and `text2qti_tk.pyw`, and place them in the same directory.
|
| 19 |
+
Double-click on `make_tk_exe.bat` to run it. Or open a command prompt,
|
| 20 |
+
navigate to `make_gui_exe/` (or wherever the batch file is located), and run
|
| 21 |
+
the batch file. Under PowerShell, run something like
|
| 22 |
+
`cmd /c make_gui_exe.bat`.
|
| 23 |
+
|
| 24 |
+
The batch file performs these steps:
|
| 25 |
+
* Create a new conda environment for building the executable.
|
| 26 |
+
* Activate the conda environment.
|
| 27 |
+
* Install needed Python packages in the environment: bespon, markdown,
|
| 28 |
+
pyinstaller, and text2qti. If the batch file detects that it is part of a
|
| 29 |
+
local copy of the text2qti source, then this local version of text2qti will
|
| 30 |
+
be used. Otherwise, text2qti will be installed from PyPI via pip.
|
| 31 |
+
* Build executable `text2qti_tk_VERSION.exe` using PyInstaller.
|
| 32 |
+
* Deactivate the conda environment.
|
| 33 |
+
* Remove the conda environment.
|
| 34 |
+
* Move the executable to the working directory.
|
| 35 |
+
* Remove all temp files and build files.
|
make_gui_exe/make_tk_exe.bat
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
REM This is intended to be run with the .bat file directory as the working dir
|
| 2 |
+
if not exist make_tk_exe.bat (
|
| 3 |
+
echo Missing make_tk_exe.bat in working directory
|
| 4 |
+
pause
|
| 5 |
+
exit
|
| 6 |
+
)
|
| 7 |
+
if not exist text2qti_tk.pyw (
|
| 8 |
+
echo Missing text2qti_tk.pyw in working directory
|
| 9 |
+
pause
|
| 10 |
+
exit
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
REM Create and activate a conda env for packaging the .exe
|
| 14 |
+
call conda create -y --name make_text2qti_gui_exe python=3.9 --no-default-packages
|
| 15 |
+
call conda activate make_text2qti_gui_exe
|
| 16 |
+
REM List conda envs -- useful for debugging
|
| 17 |
+
call conda info --envs
|
| 18 |
+
REM Install dependencies
|
| 19 |
+
pip install bespon
|
| 20 |
+
pip install markdown
|
| 21 |
+
pip install pyinstaller
|
| 22 |
+
if exist ..\setup.py (
|
| 23 |
+
if exist ..\text2qti (
|
| 24 |
+
cd ..
|
| 25 |
+
pip install .
|
| 26 |
+
cd make_gui_exe
|
| 27 |
+
) else (
|
| 28 |
+
pip install text2qti
|
| 29 |
+
)
|
| 30 |
+
) else (
|
| 31 |
+
pip install text2qti
|
| 32 |
+
)
|
| 33 |
+
REM Build .exe
|
| 34 |
+
FOR /F "tokens=* USEBACKQ" %%g IN (`python -c "import text2qti; print(text2qti.__version__)"`) do (SET "TEXT2QTI_VERSION=%%g")
|
| 35 |
+
pyinstaller -F --name text2qti_tk_%TEXT2QTI_VERSION% text2qti_tk.pyw
|
| 36 |
+
REM Deactivate and delete conda env
|
| 37 |
+
call conda deactivate
|
| 38 |
+
call conda remove -y --name make_text2qti_gui_exe --all
|
| 39 |
+
REM List conda envs -- useful for debugging
|
| 40 |
+
call conda info --envs
|
| 41 |
+
REM Cleanup
|
| 42 |
+
move dist\text2qti_tk_%TEXT2QTI_VERSION%.exe text2qti_tk_%TEXT2QTI_VERSION%.exe
|
| 43 |
+
rd /s /q "__pycache__"
|
| 44 |
+
rd /s /q "build"
|
| 45 |
+
rd /s /q "dist"
|
| 46 |
+
del *.spec
|
| 47 |
+
pause
|
make_gui_exe/text2qti_tk.pyw
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import text2qti.gui.tk
|
| 2 |
+
text2qti.gui.tk.main()
|
requirements.txt
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
-
|
| 2 |
-
pandas
|
| 3 |
-
streamlit
|
|
|
|
| 1 |
+
text2qti
|
|
|
|
|
|
setup.cfg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[metadata]
|
| 2 |
+
license_file = LICENSE.txt
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
[tool:pytest]
|
| 6 |
+
norecursedirs = build
|
setup.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
if sys.version_info < (3, 6):
|
| 13 |
+
sys.exit('text2qti requires Python 3.6+')
|
| 14 |
+
import pathlib
|
| 15 |
+
from setuptools import setup, find_packages
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Extract the version from version.py, using functions in fmtversion.py
|
| 21 |
+
fmtversion_path = pathlib.Path(__file__).parent / 'text2qti' / 'fmtversion.py'
|
| 22 |
+
exec(compile(fmtversion_path.read_text(encoding='utf8'), 'text2qti/fmtversion.py', 'exec'))
|
| 23 |
+
version_path = pathlib.Path(__file__).parent / 'text2qti' / 'version.py'
|
| 24 |
+
version = get_version_from_version_py_str(version_path.read_text(encoding='utf8'))
|
| 25 |
+
|
| 26 |
+
readme_path = pathlib.Path(__file__).parent / 'README.md'
|
| 27 |
+
long_description = readme_path.read_text(encoding='utf8')
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
setup(name='text2qti',
|
| 31 |
+
version=version,
|
| 32 |
+
py_modules=[],
|
| 33 |
+
packages=find_packages(),
|
| 34 |
+
package_data = {},
|
| 35 |
+
description='Create quizzes in QTI format from Markdown-based plain text',
|
| 36 |
+
long_description=long_description,
|
| 37 |
+
long_description_content_type='text/markdown',
|
| 38 |
+
author='Geoffrey M. Poore',
|
| 39 |
+
author_email='gpoore@gmail.com',
|
| 40 |
+
url='http://github.com/gpoore/text2qti',
|
| 41 |
+
license='BSD',
|
| 42 |
+
keywords=['QTI', 'IMS Question & Test Interoperability', 'quiz', 'test',
|
| 43 |
+
'exam', 'assessment', 'markdown', 'LaTeX', 'plain text'],
|
| 44 |
+
python_requires='>=3.6',
|
| 45 |
+
install_requires=[
|
| 46 |
+
'bespon>=0.4',
|
| 47 |
+
'markdown>=3.2',
|
| 48 |
+
],
|
| 49 |
+
# https://pypi.python.org/pypi?:action=list_classifiers
|
| 50 |
+
classifiers=[
|
| 51 |
+
'Development Status :: 4 - Beta',
|
| 52 |
+
'Environment :: Console',
|
| 53 |
+
'Intended Audience :: Education',
|
| 54 |
+
'License :: OSI Approved :: BSD License',
|
| 55 |
+
'Operating System :: OS Independent',
|
| 56 |
+
'Programming Language :: Python :: 3.6',
|
| 57 |
+
'Programming Language :: Python :: 3.7',
|
| 58 |
+
'Programming Language :: Python :: 3.8',
|
| 59 |
+
'Topic :: Education :: Testing',
|
| 60 |
+
],
|
| 61 |
+
entry_points = {
|
| 62 |
+
'console_scripts': ['text2qti = text2qti.cmdline:main'],
|
| 63 |
+
'gui_scripts': ['text2qti_tk = text2qti.gui.tk:main'],
|
| 64 |
+
},
|
| 65 |
+
)
|
temp.txt
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Quiz title: Quiz 7 extra questions
|
| 2 |
+
Quiz description: Delve deeper into the world of genetics by understanding the role of statistics in data interpretation and exploring the intricacies of Mendelian inheritance.
|
| 3 |
+
|
| 4 |
+
Title: P-Values in Hypothesis Testing
|
| 5 |
+
Points: 1
|
| 6 |
+
2. A p-value of 0.05 in a scientific study typically means:
|
| 7 |
+
... A p-value of 0.05 means that there is a 5% chance of observing the given results, or more extreme results, if the null hypothesis is true. It is a common threshold for statistical significance.
|
| 8 |
+
a) There is definitely a difference between comparison groups.
|
| 9 |
+
*b) There is a 5% chance of getting a test statistic at least as big as observed in your data if the null hypothesis is true
|
| 10 |
+
c) There is a 95% chance that the alternative hypothesis is true
|
| 11 |
+
d) The results are never statistically significant
|
| 12 |
+
|
| 13 |
+
Title: Multiple Answer - False Positives and Negatives
|
| 14 |
+
Points: 1
|
| 15 |
+
4. In the context of diagnostic tests (e.g., a COVID-19 or breast cancer test), what consequences can false positives and false negatives have?
|
| 16 |
+
... False positives can lead to unnecessary treatment or distress, while false negatives can result in a lack of treatment for a disease that is actually present.
|
| 17 |
+
[*] False positives can lead to unnecessary treatment or emotional distress
|
| 18 |
+
[ ] False negatives can lead to unnecessary treatment or emotional distress
|
| 19 |
+
[ ] False positives are always better than false negatives.
|
| 20 |
+
[*] False negatives can result in a lack of treatment for a disease that is present
|
| 21 |
+
[ ] False positive can result in a lack of treatment for a disease that is present
|
| 22 |
+
[ ] False negatives are always better than false positives.
|
| 23 |
+
[ ] False positives and false negatives are not a concern in diagnostic tests since they are rare and we can never fully eliminate error.
|
| 24 |
+
|
| 25 |
+
Title: Complete Dominance Basis
|
| 26 |
+
Points: 1
|
| 27 |
+
6. At the cellular level, what does complete dominance in Mendelian genetics typically reflect?
|
| 28 |
+
... Complete dominance usually stems from the functionality of the gene product, where one allele produces a functional protein, while the other does not.
|
| 29 |
+
*a) The presence of one functional allele compensates for a non-functional allele.
|
| 30 |
+
b) Both alleles produce functional proteins in equal quantities.
|
| 31 |
+
c) The dominant allele suppresses the expression of other genes.
|
| 32 |
+
d) Both alleles are necessary for a complete phenotypic expression.
|
| 33 |
+
|
| 34 |
+
Title: ABO Blood Type System
|
| 35 |
+
Points: 1
|
| 36 |
+
7. In the ABO blood group system, which phenomenon explains the simultaneous expression of both A and B antigens in individuals?
|
| 37 |
+
... Codominance allows for both alleles to express their phenotypes fully when present.
|
| 38 |
+
*a) Codominance
|
| 39 |
+
b) Incomplete dominance
|
| 40 |
+
c) Epistasis
|
| 41 |
+
d) Pleiotropy
|
| 42 |
+
|
| 43 |
+
Title: Impacts of Lethal Alleles
|
| 44 |
+
Points: 1
|
| 45 |
+
8. How do lethal alleles influence Mendelian ratios in genetic crosses?
|
| 46 |
+
... Lethal alleles can cause death when homozygous, leading to deviations from the expected Mendelian phenotypic ratios.
|
| 47 |
+
*a) They can cause death in homozygous individuals, leading to a lack of that genotype in the offspring.
|
| 48 |
+
b) They prevent any form of genetic crossing.
|
| 49 |
+
c) They introduce mutations into the genetic code.
|
| 50 |
+
d) They always result in a dominant phenotype.
|
| 51 |
+
|
| 52 |
+
Title: Epistasis and Phenotypic Outcomes
|
| 53 |
+
Points: 1
|
| 54 |
+
9. How does epistasis influence the phenotypic outcomes of genetic crosses?
|
| 55 |
+
... Epistasis occurs when the alleles of one gene mask or modify the expression of another gene, thereby influencing the phenotypic outcomes of genetic crosses.
|
| 56 |
+
*a) It can mask or modify the expression of another gene, altering expected phenotypic results.
|
| 57 |
+
b) It increases the mutation rate in the offspring.
|
| 58 |
+
c) It leads to incomplete dominance of one gene over another.
|
| 59 |
+
d) It ensures that Mendelian ratios are always maintained.
|
temp.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:13fc67d7538c5aef01c94679e3077dfc707bd26cc703f536906a4acd5b7d1f3d
|
| 3 |
+
size 6896
|
test_quiz.txt
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Quiz title: Introduction to Cell Biology – Quiz 2
|
| 2 |
+
Quiz description: An examination of the key concepts in cell biology, including cell types, membrane structure, transport mechanisms, energy metabolism, and enzymatic reactions.
|
| 3 |
+
|
| 4 |
+
Title: Learn with integrity
|
| 5 |
+
Points: 1
|
| 6 |
+
1. I agree to complete this quiz without the aid of others or AI. I agree not to discuss questions and answers of the quiz until the quiz key is posted. Anything otherwise is a breach of my Academic Integrity and subject to the policies of the University.
|
| 7 |
+
*a) I agree
|
| 8 |
+
b) I disagree
|
| 9 |
+
|
| 10 |
+
Title: Comparing Prokaryotic and Eukaryotic Cells
|
| 11 |
+
Points: 1
|
| 12 |
+
2. Which of the following statements best distinguishes eukaryotic cells from prokaryotic cells?
|
| 13 |
+
... Eukaryotic cells contain membrane-bound organelles, including a nucleus, while prokaryotic cells lack these structures.
|
| 14 |
+
a) Only eukaryotic cells have a plasma membrane
|
| 15 |
+
*b) Eukaryotic cells contain membrane-bound organelles, while prokaryotic cells do not
|
| 16 |
+
c) Prokaryotic cells contain more DNA than eukaryotic cells
|
| 17 |
+
d) Only prokaryotic cells contain ribosomes
|
| 18 |
+
|
| 19 |
+
Title: Phospholipids in Cell Membranes
|
| 20 |
+
Points: 1
|
| 21 |
+
3. How do the polar and nonpolar ends of phospholipids interact in water to create the bilayer structure of cell membranes?
|
| 22 |
+
... The polar (hydrophilic) heads face outward, interacting with water, while the nonpolar (hydrophobic) tails face inward, away from water, forming a bilayer.
|
| 23 |
+
*a) Polar heads face outward, nonpolar tails face inward
|
| 24 |
+
b) Polar heads face inward, nonpolar tails face outward
|
| 25 |
+
c) Polar and nonpolar ends mix randomly
|
| 26 |
+
d) Polar ends repel each other, forming a single layer
|
| 27 |
+
|
| 28 |
+
Title: Diffusion and Entropy
|
| 29 |
+
Points: 1
|
| 30 |
+
4. Why does diffusion not require work and lead to passive transport?
|
| 31 |
+
... Diffusion is the movement of molecules from an area of high concentration to low, driven by random motion, increasing entropy. It doesn't require energy input, aligning with the second law of thermodynamics.
|
| 32 |
+
*a) Diffusion increases entropy and is driven by random motion
|
| 33 |
+
b) Diffusion requires energy to move molecules against concentration gradients
|
| 34 |
+
c) Diffusion decreases entropy by organizing molecules
|
| 35 |
+
d) Diffusion is driven by active transport proteins
|
| 36 |
+
|
| 37 |
+
Title: Tonicity and Animal Cells
|
| 38 |
+
Points: 1
|
| 39 |
+
5. What would happen to an animal cell if placed in a hypertonic solution?
|
| 40 |
+
... In a hypertonic solution, the solute concentration is higher outside the cell, causing water to move out of the cell, leading to cell shrinkage or crenation.
|
| 41 |
+
*a) The cell would shrink due to water loss
|
| 42 |
+
b) The cell would swell due to water intake
|
| 43 |
+
c) The cell would remain unchanged in size
|
| 44 |
+
d) The cell would undergo lysis
|
| 45 |
+
|
| 46 |
+
Title: Functions of Smooth ER and Rough ER
|
| 47 |
+
Points: 1
|
| 48 |
+
6. How do the functions of smooth endoplasmic reticulum (ER) and rough ER differ in a eukaryotic cell?
|
| 49 |
+
... Smooth ER is involved in lipid synthesis and detoxification, while rough ER, studded with ribosomes, is involved in protein synthesis and processing.
|
| 50 |
+
*a) Smooth ER synthesizes lipids; Rough ER synthesizes proteins
|
| 51 |
+
b) Smooth ER synthesizes proteins; Rough ER synthesizes lipids
|
| 52 |
+
c) Both Smooth and Rough ER are primarily involved in protein synthesis
|
| 53 |
+
d) Both Smooth and Rough ER are primarily involved in lipid synthesis
|
| 54 |
+
|
| 55 |
+
Title: Origins of Chloroplasts and Mitochondria
|
| 56 |
+
Points: 1
|
| 57 |
+
7. According to the endosymbiotic theory, how did chloroplasts and mitochondria originate?
|
| 58 |
+
... The endosymbiotic theory proposes that chloroplasts and mitochondria originated from free-living bacteria that were engulfed by ancestral eukaryotic cells.
|
| 59 |
+
*a) They originated from free-living bacteria joining with ancestral eukaryotic cells
|
| 60 |
+
b) They originated from viral infections
|
| 61 |
+
c) They were synthesized within the host cell
|
| 62 |
+
d) They developed independently in eukaryotic cells just like the nucleus.
|
| 63 |
+
|
| 64 |
+
Title: Osmosis and Water Concentration
|
| 65 |
+
Points: 1
|
| 66 |
+
8. How can you predict the movement of water based on solute concentration?
|
| 67 |
+
... Water moves from areas of low solute concentration (high water concentration) to areas of high solute concentration (low water concentration) by osmosis.
|
| 68 |
+
*a) Water moves from low solute to high solute concentration
|
| 69 |
+
b) Water moves from high solute to low solute concentration
|
| 70 |
+
c) Water moves randomly without regard to solute concentration
|
| 71 |
+
d) Water movement is determined by temperature, not solute concentration
|
| 72 |
+
|
| 73 |
+
Title: Facilitated Diffusion vs. Active Transport
|
| 74 |
+
Points: 1
|
| 75 |
+
9. What distinguishes facilitated diffusion from active transport in cell membrane transport?
|
| 76 |
+
... Facilitated diffusion is passive and moves substances down their concentration gradient without energy input, while active transport requires energy to move substances against their gradient.
|
| 77 |
+
*a) Facilitated diffusion is passive; Active transport requires energy
|
| 78 |
+
b) Facilitated diffusion requires energy; Active transport is passive
|
| 79 |
+
c) Both facilitated diffusion and active transport require energy
|
| 80 |
+
d) Both facilitated diffusion and active transport are passive processes
|
| 81 |
+
|
| 82 |
+
Title: Bound vs. Free Ribosomes
|
| 83 |
+
Points: 1
|
| 84 |
+
10. How do bound and free ribosomes differ in terms of the destination of the proteins they synthesize?
|
| 85 |
+
... Bound ribosomes synthesize proteins that are incorporated into membranes or exported from the cell, while free ribosomes synthesize proteins that function within the cytosol.
|
| 86 |
+
*a) Bound ribosomes synthesize membrane or exported proteins; Free ribosomes synthesize proteins destined for the cytoplasm
|
| 87 |
+
b) Bound ribosomes synthesize proteins destined for the cytoplasm; Free ribosomes synthesize membrane proteins
|
| 88 |
+
c) Both bound and free ribosomes synthesize exported proteins
|
| 89 |
+
d) Both bound and free ribosomes synthesize proteins destined for the cytoplasm
|
| 90 |
+
|
| 91 |
+
Title: Lysosomal Storage Disease
|
| 92 |
+
Points: 1
|
| 93 |
+
11. Lysosomal storage diseases are often the result of a specific enzyme not working, leading to an inability to break down and recycle a cellular waste product. Which of the following scenarios would likely be associated with a lysosomal storage disease?
|
| 94 |
+
... Lysosomal storage diseases occur when there is a deficiency in an enzyme needed to break down a specific waste product, leading to its accumulation within lysosomes. This can result in cell damage and various symptoms.
|
| 95 |
+
a) A deficiency in an enzyme required in the gut for sucrose digestion.
|
| 96 |
+
b) An overproduction of a hormone regulating blood pressure
|
| 97 |
+
*c) A deficiency in an enzyme required to break down a specific cellular waste product, leading to its accumulation.
|
| 98 |
+
d) An overproduction of a gene responsible for cell growth
|
| 99 |
+
|
| 100 |
+
Title: Regulation of Exergonic Reactions
|
| 101 |
+
Points: 1
|
| 102 |
+
12. Why don't exergonic reactions in our bodies happen quickly and spontaneously in a totally unregulated manner?
|
| 103 |
+
... Exergonic reactions are thermodynamically favorable and release energy, but in biological systems, they are often controlled by enzymes and other regulatory mechanisms to ensure proper timing and coordination within metabolic pathways.
|
| 104 |
+
*a) They are prevented by transition states and high activation energies, that can only be lowered with enzymes.
|
| 105 |
+
b) They require energy input to proceed. Endergonic reactions are spontaneous.
|
| 106 |
+
c) They don't ever increase entropy, so it would require lots of work to get them going.
|
| 107 |
+
d) They are inhibited by high temperatures
|
| 108 |
+
|
| 109 |
+
Title: Reaction Rates and Temperature or Substrate Concentration
|
| 110 |
+
Points: 1
|
| 111 |
+
13. What happens to reaction rates when you change the temperature or concentration of substrate/reactants?
|
| 112 |
+
... Increasing the temperature generally accelerates reaction rates by providing more kinetic energy to the molecules. Increasing the concentration of substrate or reactants often enhances the reaction rate by increasing the frequency of collisions between molecules.
|
| 113 |
+
*a) Increasing temperature and substrate/reactant concentration generally accelerates reaction rates
|
| 114 |
+
b) Increasing temperature slows down reaction rates; increasing substrate/reactant concentration accelerates reaction rates
|
| 115 |
+
c) Increasing temperature accelerates reaction rates; increasing substrate/reactant concentration slows down reaction rates
|
| 116 |
+
d) Increasing temperature and substrate/reactant concentration generally slows down reaction rates
|
| 117 |
+
|
| 118 |
+
Title: Fatty Acid Tails of Phospholipids and Water Interaction
|
| 119 |
+
Points: 1
|
| 120 |
+
14. Why do the fatty acid tails of phospholipids prefer not to interact with water?
|
| 121 |
+
... The fatty acid tails of phospholipids are hydrophobic (nonpolar) and therefore tend to avoid interaction with water, which is a polar molecule. In a bilayer, they orient inward, away from the water, minimizing their contact with it.
|
| 122 |
+
*a) They are hydrophobic and avoid interaction with polar water molecules
|
| 123 |
+
b) They form hydrogen bonds with water
|
| 124 |
+
c) They are hydrophilic and attract water molecules
|
| 125 |
+
d) They dissolve in water, leading to membrane disintegration
|
| 126 |
+
|
| 127 |
+
Title: Essay - ATP's Role in Metabolic Reactions
|
| 128 |
+
Points: 2
|
| 129 |
+
15. Explain how ATP functions in both anabolic and catabolic metabolic reactions.
|
| 130 |
+
... ATP serves as the primary energy currency in cells, linking catabolic reactions that release energy with anabolic reactions that consume energy. Its hydrolysis provides the energy needed for various cellular functions, driving both the synthesis and breakdown of molecules.
|
| 131 |
+
_________
|
| 132 |
+
|
| 133 |
+
Title: Essay - Endomembrane System Pathway
|
| 134 |
+
Points: 2
|
| 135 |
+
16. Describe the pathway a single amino acid might take through the endomembrane system if it was destined to travel the blood stream throughout the body. Begin your story at the ribosome and end it at the plasma membrane.
|
| 136 |
+
... The endomembrane system is a complex network within eukaryotic cells, involving the synthesis, modification, and transport of proteins. The pathway includes the smooth ER for lipid synthesis, rough ER for protein synthesis, the Golgi apparatus for modification, and lysosomes for degradation.
|
| 137 |
+
_________
|
| 138 |
+
|
| 139 |
+
Title: Personal Reflection on Learning
|
| 140 |
+
Points: 2
|
| 141 |
+
17. Share one thing you learned this week that wasn't directly mentioned in this quiz. Be specific in your explanation, and teach me about this concept or idea as if I were a fellow student.
|
| 142 |
+
... Reflecting on what you've learned and teaching it to others can deepen your understanding and retention. Whether it's a novel concept, a fascinating fact, or a new perspective, I'm eager to learn from you!
|
| 143 |
+
_________
|
test_quiz.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2d5dbb85d30c59012c52c178ccc49928312b9a1e2c7c4fe6946a0da24c73a40f
|
| 3 |
+
size 11546
|
test_quiz_error.txt
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Quiz title: Introduction to Cell Biology – Quiz 2
|
| 2 |
+
Quiz description: An examination of the key concepts in cell biology, including cell types, membrane structure, transport mechanisms, energy metabolism, and enzymatic reactions.
|
| 3 |
+
|
| 4 |
+
Title: Learn with integrity
|
| 5 |
+
Points: 1
|
| 6 |
+
1. I agree to complete this quiz without the aid of others or AI. I agree not to discuss questions and answers of the quiz until the quiz key is posted. Anything otherwise is a breach of my Academic Integrity and subject to the policies of the University.
|
| 7 |
+
*a) I agree
|
| 8 |
+
b) I disagree
|
| 9 |
+
|
| 10 |
+
Title: Comparing Prokaryotic and Eukaryotic Cells
|
| 11 |
+
Points: 1
|
| 12 |
+
2. Which of the following statements best distinguishes eukaryotic cells from prokaryotic cells?
|
| 13 |
+
... Eukaryotic cells contain membrane-bound organelles, including a nucleus, while prokaryotic cells lack these structures.
|
| 14 |
+
a) Only eukaryotic cells have a plasma membrane
|
| 15 |
+
*b) Eukaryotic cells contain membrane-bound organelles, while prokaryotic cells do not
|
| 16 |
+
c) Prokaryotic cells contain more DNA than eukaryotic cells
|
| 17 |
+
d) Only prokaryotic cells contain ribosomes
|
| 18 |
+
|
| 19 |
+
Title: Phospholipids in Cell Membranes
|
| 20 |
+
Points: 1
|
| 21 |
+
3. How do the polar and nonpolar ends of phospholipids interact in water to create the bilayer structure of cell membranes?
|
| 22 |
+
... The polar (hydrophilic) heads face outward, interacting with water, while the nonpolar (hydrophobic) tails face inward, away from water, forming a bilayer.
|
| 23 |
+
#a) Polar heads face outward, nonpolar tails face inward
|
| 24 |
+
b) Polar heads face inward, nonpolar tails face outward
|
| 25 |
+
c) Polar and nonpolar ends mix randomly
|
| 26 |
+
d) Polar ends repel each other, forming a single layer
|
| 27 |
+
|
| 28 |
+
Title: Diffusion and Entropy
|
| 29 |
+
Points: 1
|
| 30 |
+
4. Why does diffusion not require work and lead to passive transport?
|
| 31 |
+
... Diffusion is the movement of molecules from an area of high concentration to low, driven by random motion, increasing entropy. It doesn't require energy input, aligning with the second law of thermodynamics.
|
| 32 |
+
*a) Diffusion increases entropy and is driven by random motion
|
| 33 |
+
b) Diffusion requires energy to move molecules against concentration gradients
|
| 34 |
+
c) Diffusion decreases entropy by organizing molecules
|
| 35 |
+
d) Diffusion is driven by active transport proteins
|
| 36 |
+
|
| 37 |
+
Title: Tonicity and Animal Cells
|
| 38 |
+
Points: 1
|
| 39 |
+
5. What would happen to an animal cell if placed in a hypertonic solution?
|
| 40 |
+
... In a hypertonic solution, the solute concentration is higher outside the cell, causing water to move out of the cell, leading to cell shrinkage or crenation.
|
| 41 |
+
*a) The cell would shrink due to water loss
|
| 42 |
+
b) The cell would swell due to water intake
|
| 43 |
+
c) The cell would remain unchanged in size
|
| 44 |
+
d) The cell would undergo lysis
|
| 45 |
+
|
| 46 |
+
Title: Functions of Smooth ER and Rough ER
|
| 47 |
+
Points: 1
|
| 48 |
+
6. How do the functions of smooth endoplasmic reticulum (ER) and rough ER differ in a eukaryotic cell?
|
| 49 |
+
... Smooth ER is involved in lipid synthesis and detoxification, while rough ER, studded with ribosomes, is involved in protein synthesis and processing.
|
| 50 |
+
*a) Smooth ER synthesizes lipids; Rough ER synthesizes proteins
|
| 51 |
+
b) Smooth ER synthesizes proteins; Rough ER synthesizes lipids
|
| 52 |
+
c) Both Smooth and Rough ER are primarily involved in protein synthesis
|
| 53 |
+
d) Both Smooth and Rough ER are primarily involved in lipid synthesis
|
| 54 |
+
|
| 55 |
+
Title: Origins of Chloroplasts and Mitochondria
|
| 56 |
+
Points: 1
|
| 57 |
+
7. According to the endosymbiotic theory, how did chloroplasts and mitochondria originate?
|
| 58 |
+
... The endosymbiotic theory proposes that chloroplasts and mitochondria originated from free-living bacteria that were engulfed by ancestral eukaryotic cells.
|
| 59 |
+
*a) They originated from free-living bacteria joining with ancestral eukaryotic cells
|
| 60 |
+
b) They originated from viral infections
|
| 61 |
+
c) They were synthesized within the host cell
|
| 62 |
+
d) They developed independently in eukaryotic cells just like the nucleus.
|
| 63 |
+
|
| 64 |
+
Title: Osmosis and Water Concentration
|
| 65 |
+
Points: 1
|
| 66 |
+
8. How can you predict the movement of water based on solute concentration?
|
| 67 |
+
... Water moves from areas of low solute concentration (high water concentration) to areas of high solute concentration (low water concentration) by osmosis.
|
| 68 |
+
*a) Water moves from low solute to high solute concentration
|
| 69 |
+
b) Water moves from high solute to low solute concentration
|
| 70 |
+
c) Water moves randomly without regard to solute concentration
|
| 71 |
+
d) Water movement is determined by temperature, not solute concentration
|
| 72 |
+
|
| 73 |
+
Title: Facilitated Diffusion vs. Active Transport
|
| 74 |
+
Points: 1
|
| 75 |
+
9. What distinguishes facilitated diffusion from active transport in cell membrane transport?
|
| 76 |
+
... Facilitated diffusion is passive and moves substances down their concentration gradient without energy input, while active transport requires energy to move substances against their gradient.
|
| 77 |
+
*a) Facilitated diffusion is passive; Active transport requires energy
|
| 78 |
+
b) Facilitated diffusion requires energy; Active transport is passive
|
| 79 |
+
c) Both facilitated diffusion and active transport require energy
|
| 80 |
+
d) Both facilitated diffusion and active transport are passive processes
|
| 81 |
+
|
| 82 |
+
Title: Bound vs. Free Ribosomes
|
| 83 |
+
Points: 1
|
| 84 |
+
10. How do bound and free ribosomes differ in terms of the destination of the proteins they synthesize?
|
| 85 |
+
... Bound ribosomes synthesize proteins that are incorporated into membranes or exported from the cell, while free ribosomes synthesize proteins that function within the cytosol.
|
| 86 |
+
*a) Bound ribosomes synthesize membrane or exported proteins; Free ribosomes synthesize proteins destined for the cytoplasm
|
| 87 |
+
b) Bound ribosomes synthesize proteins destined for the cytoplasm; Free ribosomes synthesize membrane proteins
|
| 88 |
+
c) Both bound and free ribosomes synthesize exported proteins
|
| 89 |
+
d) Both bound and free ribosomes synthesize proteins destined for the cytoplasm
|
| 90 |
+
|
| 91 |
+
Title: Lysosomal Storage Disease
|
| 92 |
+
Points: 1
|
| 93 |
+
11. Lysosomal storage diseases are often the result of a specific enzyme not working, leading to an inability to break down and recycle a cellular waste product. Which of the following scenarios would likely be associated with a lysosomal storage disease?
|
| 94 |
+
... Lysosomal storage diseases occur when there is a deficiency in an enzyme needed to break down a specific waste product, leading to its accumulation within lysosomes. This can result in cell damage and various symptoms.
|
| 95 |
+
a) A deficiency in an enzyme required in the gut for sucrose digestion.
|
| 96 |
+
b) An overproduction of a hormone regulating blood pressure
|
| 97 |
+
*c) A deficiency in an enzyme required to break down a specific cellular waste product, leading to its accumulation.
|
| 98 |
+
d) An overproduction of a gene responsible for cell growth
|
| 99 |
+
|
| 100 |
+
Title: Regulation of Exergonic Reactions
|
| 101 |
+
Points: 1
|
| 102 |
+
12. Why don't exergonic reactions in our bodies happen quickly and spontaneously in a totally unregulated manner?
|
| 103 |
+
... Exergonic reactions are thermodynamically favorable and release energy, but in biological systems, they are often controlled by enzymes and other regulatory mechanisms to ensure proper timing and coordination within metabolic pathways.
|
| 104 |
+
*a) They are prevented by transition states and high activation energies, that can only be lowered with enzymes.
|
| 105 |
+
b) They require energy input to proceed. Endergonic reactions are spontaneous.
|
| 106 |
+
c) They don't ever increase entropy, so it would require lots of work to get them going.
|
| 107 |
+
d) They are inhibited by high temperatures
|
| 108 |
+
|
| 109 |
+
Title: Reaction Rates and Temperature or Substrate Concentration
|
| 110 |
+
Points: 1
|
| 111 |
+
13. What happens to reaction rates when you change the temperature or concentration of substrate/reactants?
|
| 112 |
+
... Increasing the temperature generally accelerates reaction rates by providing more kinetic energy to the molecules. Increasing the concentration of substrate or reactants often enhances the reaction rate by increasing the frequency of collisions between molecules.
|
| 113 |
+
*a) Increasing temperature and substrate/reactant concentration generally accelerates reaction rates
|
| 114 |
+
b) Increasing temperature slows down reaction rates; increasing substrate/reactant concentration accelerates reaction rates
|
| 115 |
+
c) Increasing temperature accelerates reaction rates; increasing substrate/reactant concentration slows down reaction rates
|
| 116 |
+
d) Increasing temperature and substrate/reactant concentration generally slows down reaction rates
|
| 117 |
+
|
| 118 |
+
Title: Fatty Acid Tails of Phospholipids and Water Interaction
|
| 119 |
+
Points: 1
|
| 120 |
+
14. Why do the fatty acid tails of phospholipids prefer not to interact with water?
|
| 121 |
+
... The fatty acid tails of phospholipids are hydrophobic (nonpolar) and therefore tend to avoid interaction with water, which is a polar molecule. In a bilayer, they orient inward, away from the water, minimizing their contact with it.
|
| 122 |
+
*a) They are hydrophobic and avoid interaction with polar water molecules
|
| 123 |
+
b) They form hydrogen bonds with water
|
| 124 |
+
c) They are hydrophilic and attract water molecules
|
| 125 |
+
d) They dissolve in water, leading to membrane disintegration
|
| 126 |
+
|
| 127 |
+
Title: Essay - ATP's Role in Metabolic Reactions
|
| 128 |
+
Points: 2
|
| 129 |
+
15. Explain how ATP functions in both anabolic and catabolic metabolic reactions.
|
| 130 |
+
... ATP serves as the primary energy currency in cells, linking catabolic reactions that release energy with anabolic reactions that consume energy. Its hydrolysis provides the energy needed for various cellular functions, driving both the synthesis and breakdown of molecules.
|
| 131 |
+
_________
|
| 132 |
+
|
| 133 |
+
Title: Essay - Endomembrane System Pathway
|
| 134 |
+
Points: 2
|
| 135 |
+
16. Describe the pathway a single amino acid might take through the endomembrane system if it was destined to travel the blood stream throughout the body. Begin your story at the ribosome and end it at the plasma membrane.
|
| 136 |
+
... The endomembrane system is a complex network within eukaryotic cells, involving the synthesis, modification, and transport of proteins. The pathway includes the smooth ER for lipid synthesis, rough ER for protein synthesis, the Golgi apparatus for modification, and lysosomes for degradation.
|
| 137 |
+
_________
|
| 138 |
+
|
| 139 |
+
Title: Personal Reflection on Learning
|
| 140 |
+
Points: 2
|
| 141 |
+
17. Share one thing you learned this week that wasn't directly mentioned in this quiz. Be specific in your explanation, and teach me about this concept or idea as if I were a fellow student.
|
| 142 |
+
... Reflecting on what you've learned and teaching it to others can deepen your understanding and retention. Whether it's a novel concept, a fascinating fact, or a new perspective, I'm eager to learn from you!
|
| 143 |
+
_________
|
text2qti/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
from .version import __version__, __version_info__
|
| 12 |
+
from .config import Config
|
| 13 |
+
from .quiz import Quiz
|
| 14 |
+
from .qti import QTI
|
text2qti/cmdline.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020-2021, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import os
|
| 13 |
+
import pathlib
|
| 14 |
+
import platform
|
| 15 |
+
from re import sub
|
| 16 |
+
import shutil
|
| 17 |
+
import subprocess
|
| 18 |
+
import textwrap
|
| 19 |
+
from .version import __version__ as version
|
| 20 |
+
from .err import Text2qtiError
|
| 21 |
+
from .config import Config
|
| 22 |
+
from .quiz import Quiz
|
| 23 |
+
from .qti import QTI
|
| 24 |
+
from .export import quiz_to_pandoc
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main():
|
| 30 |
+
'''
|
| 31 |
+
text2qti executable main function.
|
| 32 |
+
'''
|
| 33 |
+
parser = argparse.ArgumentParser(prog='text2qti')
|
| 34 |
+
parser.set_defaults(func=lambda x: parser.print_help())
|
| 35 |
+
parser.add_argument('--version', action='version', version=f'text2qti {version}')
|
| 36 |
+
parser.add_argument('--latex-render-url',
|
| 37 |
+
help='URL for rendering LaTeX equations')
|
| 38 |
+
parser.add_argument('--run-code-blocks', action='store_const', const=True,
|
| 39 |
+
help='Allow special code blocks to be executed and insert their output (off by default for security)')
|
| 40 |
+
parser.add_argument('--pandoc-mathml', action='store_const', const=True,
|
| 41 |
+
help='Convert LaTeX math to MathML using Pandoc (this will create a cache file "_text2qti_cache.zip" in the quiz file directory)')
|
| 42 |
+
soln_group = parser.add_mutually_exclusive_group()
|
| 43 |
+
soln_group.add_argument('--solutions', action='append', metavar='SOLUTIONS_FILE',
|
| 44 |
+
help='Save solutions in Pandoc Markdown (.md), PDF (.pdf), or HTML (.html) format, and also create a QTI file. '
|
| 45 |
+
'Can be used multiple times to export multiple formats. '
|
| 46 |
+
'Pandoc Markdown output is only suitable for use with LaTeX or HTML; PDF output requires Pandoc plus LaTeX.')
|
| 47 |
+
soln_group.add_argument('--only-solutions', action='append', metavar='SOLUTIONS_FILE',
|
| 48 |
+
help='Save solutions in Pandoc Markdown (.md), PDF (.pdf), or HTML (.html) format, but do not create a QTI file. '
|
| 49 |
+
'Can be used multiple times to export multiple formats. '
|
| 50 |
+
'Pandoc Markdown output is only suitable for use with LaTeX or HTML; PDF output requires Pandoc plus LaTeX. '
|
| 51 |
+
'With this option, solutions and QTI may differ if executable code blocks generate problems using random numbers. '
|
| 52 |
+
'Consider creating solutions and QTI together, or setting a seed for the random number generator so it is reproducible.')
|
| 53 |
+
parser.add_argument('file',
|
| 54 |
+
help='File to convert from text to QTI')
|
| 55 |
+
args = parser.parse_args()
|
| 56 |
+
|
| 57 |
+
config = Config()
|
| 58 |
+
config.load()
|
| 59 |
+
if not config.loaded_config_file:
|
| 60 |
+
latex_render_url = input(textwrap.dedent('''\
|
| 61 |
+
It looks like text2qti has not been installed on this machine
|
| 62 |
+
before. Would you like to set a default LaTeX rendering URL? If
|
| 63 |
+
no, press ENTER. If yes, provide the URL and press ENTER.
|
| 64 |
+
|
| 65 |
+
If you use Canvas, the URL will be something like
|
| 66 |
+
https://<institution>.instructure.com/equation_images/
|
| 67 |
+
or
|
| 68 |
+
https://canvas.<institution>.edu/equation_images/
|
| 69 |
+
with "<institution>" replaced by the name or abbreviation for
|
| 70 |
+
your institution. You can determine "<institution>" by logging
|
| 71 |
+
into Canvas and then looking in the browser address bar for
|
| 72 |
+
something like "<institution>.instructure.com/" or
|
| 73 |
+
"canvas.<institution>.edu/". If the address is similar to the
|
| 74 |
+
second form, you may need to change the domain from ".edu" to
|
| 75 |
+
the appropriate value for your institution.
|
| 76 |
+
|
| 77 |
+
If you do not use Canvas or software with a compatible LaTeX
|
| 78 |
+
rendering URL, you should not set a rendering URL. You may still
|
| 79 |
+
be able to use LaTeX via the command-line option
|
| 80 |
+
"--pandoc-mathml".
|
| 81 |
+
|
| 82 |
+
LaTeX rendering URL: '''))
|
| 83 |
+
latex_render_url = latex_render_url.strip()
|
| 84 |
+
if latex_render_url:
|
| 85 |
+
config['latex_render_url'] = latex_render_url
|
| 86 |
+
config.save()
|
| 87 |
+
if args.latex_render_url is not None:
|
| 88 |
+
config['latex_render_url'] = args.latex_render_url
|
| 89 |
+
if args.run_code_blocks is not None:
|
| 90 |
+
config['run_code_blocks'] = args.run_code_blocks
|
| 91 |
+
if args.pandoc_mathml is not None:
|
| 92 |
+
config['pandoc_mathml'] = args.pandoc_mathml
|
| 93 |
+
|
| 94 |
+
file_path = pathlib.Path(args.file).expanduser()
|
| 95 |
+
file_path_abs = file_path.absolute()
|
| 96 |
+
try:
|
| 97 |
+
text = file_path.read_text(encoding='utf-8-sig') # Handle BOM for Windows
|
| 98 |
+
except FileNotFoundError:
|
| 99 |
+
raise Text2qtiError(f'File "{file_path}" does not exist')
|
| 100 |
+
except PermissionError as e:
|
| 101 |
+
raise Text2qtiError(f'File "{file_path}" cannot be read due to permission error:\n{e}')
|
| 102 |
+
except UnicodeDecodeError as e:
|
| 103 |
+
raise Text2qtiError(f'File "{file_path}" is not encoded in valid UTF-8:\n{e}')
|
| 104 |
+
|
| 105 |
+
cwd = pathlib.Path.cwd()
|
| 106 |
+
if args.solutions:
|
| 107 |
+
qti_path = pathlib.Path(f'{file_path.stem}.zip')
|
| 108 |
+
solutions_paths = [pathlib.Path(x).expanduser().absolute() for x in args.solutions]
|
| 109 |
+
elif args.only_solutions:
|
| 110 |
+
qti_path = None
|
| 111 |
+
solutions_paths = [pathlib.Path(x).expanduser().absolute() for x in args.only_solutions]
|
| 112 |
+
else:
|
| 113 |
+
qti_path = pathlib.Path(f'{file_path.stem}.zip')
|
| 114 |
+
solutions_paths = None
|
| 115 |
+
if solutions_paths is not None:
|
| 116 |
+
if file_path_abs in solutions_paths:
|
| 117 |
+
raise Text2qtiError(f'Solutions cannot overwrite quiz file "{file_path}"')
|
| 118 |
+
if not all(x.suffix.lower() in ('.md', '.markdown', '.pdf', '.html') for x in solutions_paths):
|
| 119 |
+
invalid_extensions = ', '.join(x.suffix for x in solutions_paths if x.suffix not in ('.md', '.markdown', '.pdf', '.html'))
|
| 120 |
+
raise Text2qtiError(f'Unsupported export format(s) {invalid_extensions} for solutions; use .md, .markdown, .pdf, or .html')
|
| 121 |
+
os.chdir(file_path.parent)
|
| 122 |
+
try:
|
| 123 |
+
# Quiz and any solutions should only be generated once each so that
|
| 124 |
+
# any randomization is only invoked once.
|
| 125 |
+
quiz = Quiz(text, config=config, source_name=file_path.as_posix())
|
| 126 |
+
if solutions_paths is not None:
|
| 127 |
+
solutions_text = quiz_to_pandoc(quiz, solutions=True)
|
| 128 |
+
for solutions_path in solutions_paths:
|
| 129 |
+
if solutions_path.suffix.lower() == '.pdf':
|
| 130 |
+
if not shutil.which('pandoc'):
|
| 131 |
+
raise Text2qtiError('Exporting solutions in PDF format requires Pandoc (https://pandoc.org/)')
|
| 132 |
+
if not shutil.which('pdflatex'):
|
| 133 |
+
raise Text2qtiError('Exporting solutions in PDF format requires LaTeX (https://www.tug.org/texlive/ or https://miktex.org/)')
|
| 134 |
+
if platform.system() == 'Windows':
|
| 135 |
+
cmd = [shutil.which('pandoc'), '-f', 'markdown', '-o', str(solutions_path)]
|
| 136 |
+
else:
|
| 137 |
+
cmd = ['pandoc', '-f', 'markdown', '-o', str(solutions_path)]
|
| 138 |
+
try:
|
| 139 |
+
proc = subprocess.run(
|
| 140 |
+
cmd,
|
| 141 |
+
input=solutions_text,
|
| 142 |
+
capture_output=True,
|
| 143 |
+
check=True,
|
| 144 |
+
encoding='utf8'
|
| 145 |
+
)
|
| 146 |
+
except subprocess.CalledProcessError as e:
|
| 147 |
+
raise Text2qtiError(f'Pandoc failed:\n{"-"*78}\n{e}\n{"-"*78}')
|
| 148 |
+
elif solutions_path.suffix.lower() == '.html':
|
| 149 |
+
if not shutil.which('pandoc'):
|
| 150 |
+
raise Text2qtiError('Exporting solutions in HTML format requires Pandoc (https://pandoc.org/)')
|
| 151 |
+
if platform.system() == 'Windows':
|
| 152 |
+
cmd = [shutil.which('pandoc'), '-f', 'markdown', '-o', str(solutions_path), '--mathjax', '-s']
|
| 153 |
+
else:
|
| 154 |
+
cmd = ['pandoc', '-f', 'markdown', '-o', str(solutions_path), '--mathjax', '-s']
|
| 155 |
+
try:
|
| 156 |
+
proc = subprocess.run(
|
| 157 |
+
cmd,
|
| 158 |
+
input=solutions_text,
|
| 159 |
+
capture_output=True,
|
| 160 |
+
check=True,
|
| 161 |
+
encoding='utf8'
|
| 162 |
+
)
|
| 163 |
+
except subprocess.CalledProcessError as e:
|
| 164 |
+
raise Text2qtiError(f'Pandoc failed:\n{"-"*78}\n{e}\n{"-"*78}')
|
| 165 |
+
elif solutions_path.suffix.lower() in ('.md', '.markdown'):
|
| 166 |
+
solutions_path.write_text(solutions_text, encoding='utf8')
|
| 167 |
+
else:
|
| 168 |
+
raise ValueError
|
| 169 |
+
if qti_path is not None:
|
| 170 |
+
qti = QTI(quiz)
|
| 171 |
+
qti.save(qti_path)
|
| 172 |
+
finally:
|
| 173 |
+
os.chdir(cwd)
|
text2qti/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import bespon
|
| 12 |
+
import pathlib
|
| 13 |
+
import textwrap
|
| 14 |
+
import warnings
|
| 15 |
+
from .err import Text2qtiError
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Config(dict):
|
| 21 |
+
'''
|
| 22 |
+
Dict-like configuration that raises an error when invalid keys are set.
|
| 23 |
+
If `.load()` is invoked, a config file in BespON format is loaded if it
|
| 24 |
+
exists, and otherwise is created if possible.
|
| 25 |
+
'''
|
| 26 |
+
def __init__(self, *args, **kwargs):
|
| 27 |
+
self.loaded_config_file = False
|
| 28 |
+
self.update(self._defaults)
|
| 29 |
+
self.update(dict(*args, **kwargs))
|
| 30 |
+
|
| 31 |
+
_defaults = {
|
| 32 |
+
'pandoc_mathml': False,
|
| 33 |
+
'run_code_blocks': False,
|
| 34 |
+
}
|
| 35 |
+
_key_check = {
|
| 36 |
+
'latex_render_url': lambda x: isinstance(x, str),
|
| 37 |
+
'pandoc_mathml': lambda x: isinstance(x, bool),
|
| 38 |
+
'run_code_blocks': lambda x: isinstance(x, bool),
|
| 39 |
+
}
|
| 40 |
+
_config_path = pathlib.Path('~/.text2qti.bespon').expanduser()
|
| 41 |
+
|
| 42 |
+
def __setitem__(self, key, value):
|
| 43 |
+
if key not in self._key_check:
|
| 44 |
+
raise Text2qtiError(f'Invalid configuration option "{key}"')
|
| 45 |
+
if not self._key_check[key](value):
|
| 46 |
+
raise Text2qtiError(f'Configuration option "{key}" has invalid value "{value}"')
|
| 47 |
+
super().__setitem__(key, value)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def update(self, other: dict):
|
| 51 |
+
'''
|
| 52 |
+
Send all keys through __setitem__ so that they are checked for
|
| 53 |
+
validity.
|
| 54 |
+
'''
|
| 55 |
+
for k, v in other.items():
|
| 56 |
+
self[k] = v
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def __missing__(self, key):
|
| 60 |
+
if self.loaded_config_file:
|
| 61 |
+
raise Text2qtiError(textwrap.dedent(f'''\
|
| 62 |
+
Configuration option "{key}" has not been set.
|
| 63 |
+
Open "{self._config_path}" to edit config manually.
|
| 64 |
+
'''))
|
| 65 |
+
raise Text2qtiError(f'Configuration option "{key}" has not been set.')
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
_default_config_template = textwrap.dedent('''\
|
| 69 |
+
# To set a default LaTeX rendering URL for Canvas, uncomment the
|
| 70 |
+
# appropriate config line below and replace <institution> with the name or
|
| 71 |
+
# abbreviation for your institution. You can find this by looking at
|
| 72 |
+
# your browser address bar when logged into Canvas. In some cases, more
|
| 73 |
+
# modifications may be necessary than simply replacing <institution>.
|
| 74 |
+
|
| 75 |
+
# For Canvas through Instructure:
|
| 76 |
+
# latex_render_url = "https://<institution>.instructure.com/equation_images/"
|
| 77 |
+
|
| 78 |
+
# For Canvas through your institution (may need to change ".edu" domain):
|
| 79 |
+
# latex_render_url = "https://canvas.<institution>.edu/equation_images/"
|
| 80 |
+
''')
|
| 81 |
+
|
| 82 |
+
def load(self):
|
| 83 |
+
'''
|
| 84 |
+
Load config file.
|
| 85 |
+
'''
|
| 86 |
+
config_path = self._config_path
|
| 87 |
+
config_text = None
|
| 88 |
+
try:
|
| 89 |
+
config_text = config_path.read_text('utf8')
|
| 90 |
+
self.loaded_config_file = True
|
| 91 |
+
except FileNotFoundError:
|
| 92 |
+
try:
|
| 93 |
+
config_path.write_text(self._default_config_template, encoding='utf8')
|
| 94 |
+
except FileNotFoundError:
|
| 95 |
+
warnings.warn(f'Could not create default text2qti config file "{config_path}" due to FileNotFoundError (directory does not exist).')
|
| 96 |
+
except PermissionError:
|
| 97 |
+
warnings.warn(f'Could not create default text2qti config file "{config_path}" due to PermissionError.')
|
| 98 |
+
except PermissionError:
|
| 99 |
+
raise Text2qtiError(f'Could not open text2qti config file "{config_path}" due to PermissionError.')
|
| 100 |
+
except UnicodeDecodeError:
|
| 101 |
+
raise Text2qtiError(f'Could not open text2qti config file "{config_path}" due to UnicodeDecodeError. File may be corrupt.')
|
| 102 |
+
|
| 103 |
+
if config_text is not None:
|
| 104 |
+
try:
|
| 105 |
+
config_dict = bespon.loads(config_text, empty_default=dict)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
raise Text2qtiError(f'Failed to load config file "{config_path}":\n{e}')
|
| 108 |
+
try:
|
| 109 |
+
self.update(config_dict)
|
| 110 |
+
except Text2qtiError as e:
|
| 111 |
+
raise Text2qtiError(f'Failed to load config file "{config_path}":\n{e}')
|
| 112 |
+
|
| 113 |
+
def save(self):
|
| 114 |
+
'''
|
| 115 |
+
Save config file.
|
| 116 |
+
'''
|
| 117 |
+
config_path = self._config_path
|
| 118 |
+
try:
|
| 119 |
+
bespon_text = bespon.dumps(dict(self))
|
| 120 |
+
except Exception as e:
|
| 121 |
+
raise Text2qtiError(f'Failed to convert config data to config file format (invalid data?):\n{e}')
|
| 122 |
+
try:
|
| 123 |
+
config_path.write_text(bespon_text, 'utf8')
|
| 124 |
+
except FileNotFoundError:
|
| 125 |
+
raise Text2qtiError(f'Could not create text2qti config file "{config_path}" due to FileNotFoundError (directory does not exist).')
|
| 126 |
+
except PermissionError:
|
| 127 |
+
raise Text2qtiError(f'Could not create text2qti config file "{config_path}" due to PermissionError.')
|
text2qti/err.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Text2qtiError(Exception):
|
| 12 |
+
pass
|
text2qti/export.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2021, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
import re
|
| 13 |
+
import textwrap
|
| 14 |
+
|
| 15 |
+
from .quiz import Quiz, Question, GroupStart, GroupEnd, TextRegion
|
| 16 |
+
from .markdown import Markdown
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
markdown = Markdown()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# https://daringfireball.net/projects/markdown/syntax
|
| 23 |
+
_md_escape_chars_re = re.compile(r'[\\`*_{}\[\]()#+\-.!]')
|
| 24 |
+
|
| 25 |
+
def _md_escape_chars_repl_func(match: re.Match) -> str:
|
| 26 |
+
return '\\' + match.group()
|
| 27 |
+
|
| 28 |
+
def md_escape(raw_text: str) -> str:
|
| 29 |
+
'''
|
| 30 |
+
Escape raw text so that it is suitable for inclusion in Markdown.
|
| 31 |
+
'''
|
| 32 |
+
return _md_escape_chars_re.sub(_md_escape_chars_repl_func, raw_text)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def indent(text: str, n_spaces: int, first_line: bool=True):
|
| 36 |
+
'''
|
| 37 |
+
Indent a string by a specified number of spaces, optionally leaving the
|
| 38 |
+
first line unchanged.
|
| 39 |
+
'''
|
| 40 |
+
if n_spaces < 0:
|
| 41 |
+
raise ValueError
|
| 42 |
+
if n_spaces == 0 or not text:
|
| 43 |
+
return text
|
| 44 |
+
indent_spaces = ' '*n_spaces
|
| 45 |
+
indented_text = text.replace('\n', '\n' + indent_spaces)
|
| 46 |
+
indented_text = indented_text.replace('\n' + indent_spaces + '\n', '\n\n')
|
| 47 |
+
if text[-1] == '\n':
|
| 48 |
+
indented_text = indented_text.rstrip(' ')
|
| 49 |
+
if first_line and text[0] != '\n':
|
| 50 |
+
indented_text = indent_spaces + indented_text
|
| 51 |
+
return indented_text
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
_latex_templates = {
|
| 55 |
+
'header': textwrap.dedent(
|
| 56 |
+
r'''
|
| 57 |
+
%%%% Begin text2qti custom preamble
|
| 58 |
+
% Page layout
|
| 59 |
+
\usepackage[margin=1in]{geometry}
|
| 60 |
+
% Graphics
|
| 61 |
+
\usepackage{graphicx}
|
| 62 |
+
% Math/science
|
| 63 |
+
\usepackage{amsmath, amssymb}
|
| 64 |
+
\usepackage{siunitx}
|
| 65 |
+
% Symbols for solutions
|
| 66 |
+
\usepackage{fontawesome}
|
| 67 |
+
% Answers and solutions use itemize with custom item symbols
|
| 68 |
+
\def\texttoqtimctfchoicesymb{%
|
| 69 |
+
\resizebox{2ex}{!}{\faCircleO}}
|
| 70 |
+
\def\texttoqtimctfcorrectchoicesymb{%
|
| 71 |
+
\resizebox{2ex}{!}{\faDotCircleO}}
|
| 72 |
+
\def\texttoqtimultanschoicesymb{%
|
| 73 |
+
\resizebox{2ex}{!}{\faSquareO}}
|
| 74 |
+
\def\texttoqtimultanscorrectchoicesymb{%
|
| 75 |
+
\resizebox{2ex}{!}{\faCheckSquare}}
|
| 76 |
+
\def\texttoqtigeneralcorrectanssymb{%
|
| 77 |
+
\resizebox{2ex}{!}{\faArrowRight}}
|
| 78 |
+
\def\texttoqtisolutionsymb{%
|
| 79 |
+
\resizebox{2ex}{!}{\faFileTextO}}
|
| 80 |
+
\def\texttoqtishortansbox{%
|
| 81 |
+
\framebox[0.25\linewidth]{\strut}}
|
| 82 |
+
\def\texttoqtiessayansbox{%
|
| 83 |
+
\framebox{\begin{minipage}\vspace{4\baselineskip}\end{minipage}}}
|
| 84 |
+
%%%% End text2qti custom preamble
|
| 85 |
+
'''[1:]
|
| 86 |
+
),
|
| 87 |
+
'mctf_choice_start': r'\item[\texttoqtimctfchoicesymb]',
|
| 88 |
+
'mctf_choice_end': r'',
|
| 89 |
+
'mctf_correct_choice_start': r'\item[\texttoqtimctfcorrectchoicesymb]',
|
| 90 |
+
'mctf_correct_choice_end': r'',
|
| 91 |
+
'multans_choice_start': r'\item[\texttoqtimultanschoicesymb]',
|
| 92 |
+
'multans_choice_end': r'',
|
| 93 |
+
'multans_correct_choice_start': r'\item[\texttoqtimultanscorrectchoicesymb]',
|
| 94 |
+
'multans_correct_choice_end': r'',
|
| 95 |
+
'generic_correct_choice_start': r'\item[\texttoqtigeneralcorrectanssymb]',
|
| 96 |
+
'generic_correct_choice_end': r'',
|
| 97 |
+
'choices_start': r'\begin{itemize}',
|
| 98 |
+
'choices_end': r'\end{itemize}',
|
| 99 |
+
'solution_start': r'\begin{itemize}' +'\n' + r'\item[\texttoqtisolutionsymb]',
|
| 100 |
+
'solution_end': r'\end{itemize}',
|
| 101 |
+
'shortans_placeholder': r'\texttoqtishortansbox',
|
| 102 |
+
'essay_placeholder': r'\texttoqtiessayansbox',
|
| 103 |
+
'file_upload_placeholder': r'\framebox{\texttt{<file upload>}}',
|
| 104 |
+
'random_questions_start': r'\begingroup\renewcommand{\labelitemi}{[?]}',
|
| 105 |
+
'random_questions_end': r'\endgroup',
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
_html_templates = {
|
| 109 |
+
'header': textwrap.dedent(
|
| 110 |
+
'''
|
| 111 |
+
<style type="text/css">
|
| 112 |
+
html {
|
| 113 |
+
line-height: 1.2;
|
| 114 |
+
}
|
| 115 |
+
div.text2qti-randomized > ul {
|
| 116 |
+
list-style-position: outside;
|
| 117 |
+
margin-left: -0.5em;
|
| 118 |
+
}
|
| 119 |
+
div.text2qti-randomized > ul > li {
|
| 120 |
+
list-style-type: "[?]";
|
| 121 |
+
padding-left: 0.5em;
|
| 122 |
+
}
|
| 123 |
+
ul.text2qti {
|
| 124 |
+
list-style-position: outside;
|
| 125 |
+
/* margin-left: -0.5em; */
|
| 126 |
+
}
|
| 127 |
+
ul > li.text2qti-mctf-choice::marker, ul > li.text2qti-mctf-correct-choice::marker {
|
| 128 |
+
font-size: 1.5em;
|
| 129 |
+
}
|
| 130 |
+
ul > li.text2qti-mctf-choice, ul > li.text2qti-mctf-correct-choice {
|
| 131 |
+
margin-top: -0.5em;
|
| 132 |
+
}
|
| 133 |
+
li.text2qti-mctf-choice {
|
| 134 |
+
list-style-type: "○";
|
| 135 |
+
padding-left: 0.5em;
|
| 136 |
+
}
|
| 137 |
+
li.text2qti-mctf-correct-choice {
|
| 138 |
+
list-style-type: "●";
|
| 139 |
+
padding-left: 0.5em;
|
| 140 |
+
}
|
| 141 |
+
li.text2qti-multans-choice {
|
| 142 |
+
list-style-type: "☐";
|
| 143 |
+
padding-left: 0.5em;
|
| 144 |
+
}
|
| 145 |
+
li.text2qti-multans-correct-choice {
|
| 146 |
+
list-style-type: "☑";
|
| 147 |
+
padding-left: 0.5em;
|
| 148 |
+
}
|
| 149 |
+
li.text2qti-generic-correct {
|
| 150 |
+
list-style-type: "🡆";
|
| 151 |
+
padding-left: 0.5em;
|
| 152 |
+
}
|
| 153 |
+
ul > li.text2qti-solution::marker {
|
| 154 |
+
font-size: 1.75em;
|
| 155 |
+
}
|
| 156 |
+
ul > li.text2qti-solution {
|
| 157 |
+
margin-top: -0.25em;
|
| 158 |
+
}
|
| 159 |
+
li.text2qti-solution {
|
| 160 |
+
list-style-type: "🗈";
|
| 161 |
+
padding-left: 0.5em;
|
| 162 |
+
}
|
| 163 |
+
</style>
|
| 164 |
+
'''[1:]
|
| 165 |
+
),
|
| 166 |
+
'mctf_choice_start': '<li class="text2qti-mctf-choice">',
|
| 167 |
+
'mctf_choice_end': '</li>',
|
| 168 |
+
'mctf_correct_choice_start': '<li class="text2qti-mctf-correct-choice">',
|
| 169 |
+
'mctf_correct_choice_end': '</li>',
|
| 170 |
+
'multans_choice_start': '<li class="text2qti-multans-choice">',
|
| 171 |
+
'multans_choice_end': '</li>',
|
| 172 |
+
'multans_correct_choice_start': '<li class="text2qti-multans-correct-choice">',
|
| 173 |
+
'multans_correct_choice_end': '</li>',
|
| 174 |
+
'generic_correct_choice_start': '<li class="text2qti-generic-correct">',
|
| 175 |
+
'generic_correct_choice_end': '</li>',
|
| 176 |
+
'choices_start': '<ul class="text2qti">',
|
| 177 |
+
'choices_end': '</ul>',
|
| 178 |
+
'solution_start': '<ul class="text2qti"><li class="text2qti-solution">',
|
| 179 |
+
'solution_end': '</ul></li>',
|
| 180 |
+
'shortans_placeholder': '<div style="width:20em; height:1.5em; border:1px solid black;"></div>',
|
| 181 |
+
'essay_placeholder': '<div style="width:100%; height:6em; border:1px solid black;"></div>',
|
| 182 |
+
'file_upload_placeholder': '<pre style="border:1px solid black;"><file upload></pre>',
|
| 183 |
+
'random_questions_start': '<div class="text2qti-randomized">',
|
| 184 |
+
'random_questions_end': '</div>',
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
_templates = {}
|
| 188 |
+
template = textwrap.dedent(
|
| 189 |
+
r'''
|
| 190 |
+
```{=latex}
|
| 191 |
+
!{latex}
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
```{=html}
|
| 195 |
+
!{html}
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
'''[1:]
|
| 199 |
+
)
|
| 200 |
+
for key in _latex_templates:
|
| 201 |
+
_templates[key] = template.replace('!{latex}', _latex_templates[key]).replace('!{html}', _html_templates[key])
|
| 202 |
+
del template
|
| 203 |
+
_templates['divider'] = '{0}\n\n'.format('-'*78)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def question_to_markdown(question: Question, *,
|
| 207 |
+
solutions: bool, unordered: bool,
|
| 208 |
+
show_points: bool=False) -> str:
|
| 209 |
+
'''
|
| 210 |
+
Convert a question to Markdown
|
| 211 |
+
'''
|
| 212 |
+
if not solutions:
|
| 213 |
+
raise NotImplementedError
|
| 214 |
+
|
| 215 |
+
quiz_md = []
|
| 216 |
+
|
| 217 |
+
# List marker and point value
|
| 218 |
+
if solutions and unordered:
|
| 219 |
+
quiz_md.append('* ')
|
| 220 |
+
else:
|
| 221 |
+
quiz_md.append('@. ')
|
| 222 |
+
if show_points:
|
| 223 |
+
quiz_md.append('**[{0}]** '.format(question.points_possible))
|
| 224 |
+
quiz_md.append(indent(markdown.md_to_pandoc(question.question_raw), 4, first_line=False))
|
| 225 |
+
quiz_md.append('\n\n')
|
| 226 |
+
|
| 227 |
+
if question.type in ('true_false_question', 'multiple_choice_question'):
|
| 228 |
+
quiz_md.append(indent(_templates['choices_start'], 4))
|
| 229 |
+
for choice in question.choices:
|
| 230 |
+
if solutions and choice.correct:
|
| 231 |
+
quiz_md.append(indent(_templates['mctf_correct_choice_start'], 4))
|
| 232 |
+
else:
|
| 233 |
+
quiz_md.append(indent(_templates['mctf_choice_start'], 4))
|
| 234 |
+
quiz_md.append(indent(markdown.md_to_pandoc(choice.choice_raw), 4))
|
| 235 |
+
quiz_md.append('\n\n')
|
| 236 |
+
if solutions and choice.correct:
|
| 237 |
+
quiz_md.append(indent(_templates['mctf_correct_choice_end'], 4))
|
| 238 |
+
else:
|
| 239 |
+
quiz_md.append(indent(_templates['mctf_choice_end'], 4))
|
| 240 |
+
quiz_md.append(indent(_templates['choices_end'], 4))
|
| 241 |
+
elif question.type == 'multiple_answers_question':
|
| 242 |
+
quiz_md.append(indent(_templates['choices_start'], 4))
|
| 243 |
+
for choice in question.choices:
|
| 244 |
+
if solutions and choice.correct:
|
| 245 |
+
quiz_md.append(indent(_templates['multans_correct_choice_start'], 4))
|
| 246 |
+
else:
|
| 247 |
+
quiz_md.append(indent(_templates['multans_choice_start'], 4))
|
| 248 |
+
quiz_md.append(indent(markdown.md_to_pandoc(choice.choice_raw), 4))
|
| 249 |
+
quiz_md.append('\n\n')
|
| 250 |
+
if solutions and choice.correct:
|
| 251 |
+
quiz_md.append(indent(_templates['multans_correct_choice_end'], 4))
|
| 252 |
+
else:
|
| 253 |
+
quiz_md.append(indent(_templates['multans_choice_end'], 4))
|
| 254 |
+
quiz_md.append(indent(_templates['choices_end'], 4))
|
| 255 |
+
elif question.type == 'short_answer_question':
|
| 256 |
+
if solutions:
|
| 257 |
+
quiz_md.append(indent(_templates['choices_start'], 4))
|
| 258 |
+
quiz_md.append(indent(_templates['generic_correct_choice_start'], 4))
|
| 259 |
+
quiz_md.append(indent(' | '.join(markdown.md_to_pandoc(choice.choice_raw) for choice in question.choices), 4))
|
| 260 |
+
quiz_md.append('\n\n')
|
| 261 |
+
quiz_md.append(indent(_templates['generic_correct_choice_end'], 4))
|
| 262 |
+
quiz_md.append(indent(_templates['choices_end'], 4))
|
| 263 |
+
else:
|
| 264 |
+
quiz_md.append(indent(_templates['shortans_placeholder'], 4))
|
| 265 |
+
elif question.type == 'numerical_question':
|
| 266 |
+
if solutions:
|
| 267 |
+
quiz_md.append(indent(_templates['choices_start'], 4))
|
| 268 |
+
quiz_md.append(indent(_templates['generic_correct_choice_start'], 4))
|
| 269 |
+
ans = question.numerical_raw
|
| 270 |
+
if '+-' in ans:
|
| 271 |
+
while '+- ' in ans:
|
| 272 |
+
ans = ans.replace('+- ', '+-')
|
| 273 |
+
if ans.endswith('+-0'):
|
| 274 |
+
ans = ans[:-3]
|
| 275 |
+
else:
|
| 276 |
+
ans = ans.replace('+-', r'\pm ')
|
| 277 |
+
ans = ans.replace('%', r'\%')
|
| 278 |
+
quiz_md.append(indent('$', 4))
|
| 279 |
+
quiz_md.append(ans)
|
| 280 |
+
if question.numerical_min is not None and question.numerical_max is not None:
|
| 281 |
+
if '+-' in question.numerical_raw:
|
| 282 |
+
if isinstance(question.numerical_min, int) and isinstance(question.numerical_max, int):
|
| 283 |
+
quiz_md.append(rf' \quad \Rightarrow \quad [{question.numerical_min}, {question.numerical_max}]')
|
| 284 |
+
else:
|
| 285 |
+
quiz_md.append(rf' \quad \Rightarrow \quad [{question.numerical_min:.4f}, {question.numerical_max:.4f}]')
|
| 286 |
+
quiz_md.append('$')
|
| 287 |
+
quiz_md.append('\n\n')
|
| 288 |
+
quiz_md.append(indent(_templates['generic_correct_choice_end'], 4))
|
| 289 |
+
quiz_md.append(indent(_templates['choices_end'], 4))
|
| 290 |
+
elif question.type == 'essay_question':
|
| 291 |
+
if not solutions:
|
| 292 |
+
quiz_md.append(indent(_templates['essay_placeholder'], 4))
|
| 293 |
+
elif question.type == 'file_upload_question':
|
| 294 |
+
if not solutions:
|
| 295 |
+
quiz_md.append(indent(_templates['file_upload_placeholder'], 4))
|
| 296 |
+
else:
|
| 297 |
+
raise ValueError
|
| 298 |
+
|
| 299 |
+
if solutions and question.solution is not None:
|
| 300 |
+
quiz_md.append(indent(_templates['solution_start'], 4))
|
| 301 |
+
quiz_md.append(indent(markdown.md_to_pandoc(question.solution), 4))
|
| 302 |
+
quiz_md.append('\n\n')
|
| 303 |
+
quiz_md.append(indent(_templates['solution_end'], 4))
|
| 304 |
+
|
| 305 |
+
return ''.join(quiz_md)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def quiz_to_pandoc(quiz: Quiz, *, solutions=False) -> str:
|
| 309 |
+
'''
|
| 310 |
+
Generate a Pandoc Markdown version of assessment that optionally includes
|
| 311 |
+
solutions.
|
| 312 |
+
'''
|
| 313 |
+
if not solutions:
|
| 314 |
+
raise NotImplementedError
|
| 315 |
+
|
| 316 |
+
quiz_md = []
|
| 317 |
+
|
| 318 |
+
title = md_escape(quiz.title_raw or 'Quiz')
|
| 319 |
+
title += r'`\\ \textsc{solutions}`{=latex}'
|
| 320 |
+
title += r'`<br><span style="font-variant: small-caps;">solutions</span>`{=html}'
|
| 321 |
+
title = title.replace('\\', '\\\\').replace(r'"', r'\"')
|
| 322 |
+
meta = textwrap.dedent(
|
| 323 |
+
r'''
|
| 324 |
+
---
|
| 325 |
+
title: "{title}"
|
| 326 |
+
header-includes: |
|
| 327 |
+
{header}
|
| 328 |
+
...
|
| 329 |
+
|
| 330 |
+
'''[1:]
|
| 331 |
+
)
|
| 332 |
+
meta = meta.format(title=title, header=indent(_templates['header'], 4, first_line=False))
|
| 333 |
+
quiz_md.append(meta)
|
| 334 |
+
|
| 335 |
+
if quiz.description_raw:
|
| 336 |
+
quiz_md.append(markdown.md_to_pandoc(quiz.description_raw))
|
| 337 |
+
quiz_md.append('\n\n')
|
| 338 |
+
quiz_md.append(_templates['divider'])
|
| 339 |
+
|
| 340 |
+
len_quiz_md_before_questions = len(quiz_md)
|
| 341 |
+
in_group = False
|
| 342 |
+
group_needs_divider = False
|
| 343 |
+
for question_or_delim in quiz.questions_and_delims:
|
| 344 |
+
if isinstance(question_or_delim, TextRegion):
|
| 345 |
+
if question_or_delim.title_raw:
|
| 346 |
+
if len(quiz_md) > len_quiz_md_before_questions and quiz_md[-1] != _templates['divider']:
|
| 347 |
+
quiz_md.append(_templates['divider'])
|
| 348 |
+
quiz_md.append('## {0}\n\n'.format(md_escape(question_or_delim.title_raw.replace('\n', ' '))))
|
| 349 |
+
if question_or_delim.text_raw:
|
| 350 |
+
quiz_md.append(markdown.md_to_pandoc(question_or_delim.text_raw))
|
| 351 |
+
quiz_md.append('\n\n')
|
| 352 |
+
quiz_md.append(_templates['divider'])
|
| 353 |
+
continue
|
| 354 |
+
if isinstance(question_or_delim, GroupStart):
|
| 355 |
+
in_group = True
|
| 356 |
+
group = question_or_delim.group
|
| 357 |
+
if solutions:
|
| 358 |
+
if group.solutions_pick is not None:
|
| 359 |
+
num_questions_displayed = group.solutions_pick
|
| 360 |
+
elif quiz.solutions_sample_groups:
|
| 361 |
+
num_questions_displayed = group.pick
|
| 362 |
+
else:
|
| 363 |
+
num_questions_displayed = len(group.questions)
|
| 364 |
+
if num_questions_displayed > 1:
|
| 365 |
+
if len(quiz_md) > len_quiz_md_before_questions and quiz_md[-1] != _templates['divider']:
|
| 366 |
+
quiz_md.append(_templates['divider'])
|
| 367 |
+
group_needs_divider = True
|
| 368 |
+
if group.pick == 1:
|
| 369 |
+
if num_questions_displayed == 1:
|
| 370 |
+
quiz_md.append(f'### Randomized question: representative example is shown\n\n')
|
| 371 |
+
elif num_questions_displayed < len(group.questions):
|
| 372 |
+
quiz_md.append(f'### Randomized question: randomly select {group.pick} from representative examples shown\n\n')
|
| 373 |
+
else:
|
| 374 |
+
quiz_md.append(f'### Randomized question: randomly select {group.pick}\n\n')
|
| 375 |
+
else:
|
| 376 |
+
if num_questions_displayed == group.pick:
|
| 377 |
+
quiz_md.append(f'### Randomized questions: representative examples are shown\n\n')
|
| 378 |
+
elif num_questions_displayed < len(group.questions):
|
| 379 |
+
quiz_md.append(f'### Randomized questions: randomly select {group.pick} from representative examples shown\n\n')
|
| 380 |
+
else:
|
| 381 |
+
quiz_md.append(f'### Randomized questions: randomly select {group.pick}\n\n')
|
| 382 |
+
if num_questions_displayed != group.pick:
|
| 383 |
+
for _ in range(group.pick):
|
| 384 |
+
quiz_md.append('@. `<randomly selected>`\n\n')
|
| 385 |
+
unordered = num_questions_displayed != group.pick
|
| 386 |
+
if unordered:
|
| 387 |
+
quiz_md.append(_templates['random_questions_start'])
|
| 388 |
+
if quiz.solutions_randomize_groups:
|
| 389 |
+
for question in random.choices(question_or_delim.group.questions, num_questions_displayed):
|
| 390 |
+
quiz_md.append(question_to_markdown(question, solutions=solutions, unordered=unordered))
|
| 391 |
+
else:
|
| 392 |
+
for question in question_or_delim.group.questions[:num_questions_displayed]:
|
| 393 |
+
quiz_md.append(question_to_markdown(question, solutions=solutions, unordered=unordered))
|
| 394 |
+
if unordered:
|
| 395 |
+
quiz_md.append(_templates['random_questions_end'])
|
| 396 |
+
if group_needs_divider:
|
| 397 |
+
quiz_md.append(_templates['divider'])
|
| 398 |
+
group_needs_divider = False
|
| 399 |
+
continue
|
| 400 |
+
if isinstance(question_or_delim, GroupEnd):
|
| 401 |
+
in_group = False
|
| 402 |
+
continue
|
| 403 |
+
if isinstance(question_or_delim, Question):
|
| 404 |
+
if in_group:
|
| 405 |
+
continue
|
| 406 |
+
quiz_md.append(question_to_markdown(question_or_delim, solutions=solutions, unordered=in_group))
|
| 407 |
+
continue
|
| 408 |
+
raise TypeError
|
| 409 |
+
|
| 410 |
+
if quiz_md and quiz_md[-1] is _templates['divider']:
|
| 411 |
+
quiz_md.pop()
|
| 412 |
+
return ''.join(quiz_md)
|
text2qti/fmtversion.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2015-2018, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
'''
|
| 12 |
+
=============================================================
|
| 13 |
+
``fmtversion``: Simple version variables for Python packages
|
| 14 |
+
=============================================================
|
| 15 |
+
|
| 16 |
+
:Author: Geoffrey M. Poore
|
| 17 |
+
:License: `BSD 3-Clause <http://opensource.org/licenses/BSD-3-Clause>`_
|
| 18 |
+
|
| 19 |
+
Converts version information into a string ``__version__`` and a namedtuple
|
| 20 |
+
``__version_info__`` suitable for Python packages. The approach is inspired
|
| 21 |
+
by PEP 440 and ``sys.version_info``:
|
| 22 |
+
|
| 23 |
+
* https://www.python.org/dev/peps/pep-0440
|
| 24 |
+
* https://docs.python.org/3/library/sys.html
|
| 25 |
+
|
| 26 |
+
Versions of the form "major.minor.micro" are supported, with an optional,
|
| 27 |
+
numbered dev/alpha/beta/candidate/final/post status. The module does not
|
| 28 |
+
support more complicated version numbers like "1.0b2.post345.dev456", since
|
| 29 |
+
this does not fit into a namedtuple of the form used by ``sys.version_info``.
|
| 30 |
+
The code is written as a single module, so that it may be bundled into
|
| 31 |
+
packages, rather than needing to be installed as a separate dependency.
|
| 32 |
+
|
| 33 |
+
Typical usage in a package's ``version.py`` with a bundled ``fmtversion.py``::
|
| 34 |
+
|
| 35 |
+
from .fmtversion import get_version_plus_info
|
| 36 |
+
__version__, __version_info__ = get_version_plus_info(1, 1, 0, 'final', 0)
|
| 37 |
+
|
| 38 |
+
Following ``sys.version_info``, ``get_version_plus_info()`` takes arguments
|
| 39 |
+
for a five-component version number: major, minor, micro, releaselevel, and
|
| 40 |
+
serial. The releaselevel may be any of dev, alpha, beta, candidate, final, or
|
| 41 |
+
post, or common variations/abbreviations thereof. All arguments but
|
| 42 |
+
releaselevel must be convertable to integers.
|
| 43 |
+
|
| 44 |
+
If only ``__version__`` or ``__version_info__`` is desired, then the functions
|
| 45 |
+
``get_version()`` or ``get_version_info()`` may be used instead. If a micro
|
| 46 |
+
version is not needed (``<major>.<minor>.<micro>``), then set the optional
|
| 47 |
+
keyword argument ``usemicro=False``. This will omit a micro version from the
|
| 48 |
+
string ``__version__``, while the namedtuple ``__version_info__`` will still
|
| 49 |
+
have a field ``micro`` that is set to zero to simplify comparisons. If each
|
| 50 |
+
releaselevel will only have one release, then set ``useserial=False``. This
|
| 51 |
+
will omit a serial number from the string ``__version__``, while the
|
| 52 |
+
namedtuple ``__version_info__`` will still have a field ``serial`` that is set
|
| 53 |
+
to zero.
|
| 54 |
+
|
| 55 |
+
A function ``get_version_from_version_py_str()`` is included for extracting
|
| 56 |
+
the version from a ``version.py`` file that has been read into a string. This
|
| 57 |
+
is intended for use in ``setup.py`` files.
|
| 58 |
+
'''
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
from __future__ import (division, print_function, absolute_import,
|
| 64 |
+
unicode_literals)
|
| 65 |
+
import sys
|
| 66 |
+
if sys.version_info.major == 2:
|
| 67 |
+
str = basestring
|
| 68 |
+
import collections
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# Version of this module, which will later be converted into a `__version__`
|
| 72 |
+
# and `__version_info__` once the necessary functions are created
|
| 73 |
+
__version_tuple__ = (1, 1, 0, 'final', 0)
|
| 74 |
+
|
| 75 |
+
__docformat__ = 'restructuredtext en'
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
VersionInfo = collections.namedtuple('VersionInfo', ['major', 'minor', 'micro',
|
| 81 |
+
'releaselevel', 'serial'])
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_version_info(major, minor, micro, releaselevel, serial,
|
| 85 |
+
usemicro=True, useserial=True):
|
| 86 |
+
'''
|
| 87 |
+
Create a VersionInfo instance suitable for use as `__version_info__`.
|
| 88 |
+
|
| 89 |
+
Perform all type and value checking that is needed for arguments; assume
|
| 90 |
+
that no previous checks have been performed. This allows all checks to be
|
| 91 |
+
centralized in this single function.
|
| 92 |
+
'''
|
| 93 |
+
if not all(isinstance(x, int) or isinstance(x, str)
|
| 94 |
+
for x in (major, minor, micro, serial)):
|
| 95 |
+
raise TypeError('major, minor, micro, and serial must be integers or strings')
|
| 96 |
+
if not isinstance(releaselevel, str):
|
| 97 |
+
raise TypeError('releaselevel must be a string')
|
| 98 |
+
if not all(isinstance(x, bool) for x in (usemicro, useserial)):
|
| 99 |
+
raise TypeError('usemicro and useserial must be bools')
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
major = int(major)
|
| 103 |
+
minor = int(minor)
|
| 104 |
+
micro = int(micro)
|
| 105 |
+
serial = int(serial)
|
| 106 |
+
except ValueError:
|
| 107 |
+
raise ValueError('major, minor, micro, and serial must be convertable to integers')
|
| 108 |
+
if any(x < 0 for x in (major, minor, micro, serial)):
|
| 109 |
+
raise ValueError('major, minor, micro, and serial must correspond to non-negative integers')
|
| 110 |
+
if not usemicro and micro != 0:
|
| 111 |
+
raise ValueError('usemicro=False, but a micro value "{0}" has been set'.format(micro))
|
| 112 |
+
if not useserial and serial != 0:
|
| 113 |
+
raise ValueError('useserial=False, but a serial value "{0}" has been set'.format(serial))
|
| 114 |
+
|
| 115 |
+
releaselevel_dict = {'dev': 'dev',
|
| 116 |
+
'a': 'a', 'alpha': 'a',
|
| 117 |
+
'b': 'b', 'beta': 'b',
|
| 118 |
+
'c': 'c', 'rc': 'c',
|
| 119 |
+
'candidate': 'c', 'releasecandidate': 'c',
|
| 120 |
+
'pre': 'c', 'preview': 'c',
|
| 121 |
+
'final': 'final',
|
| 122 |
+
'post': 'post', 'r': 'post', 'rev': 'post'}
|
| 123 |
+
try:
|
| 124 |
+
releaselevel = releaselevel_dict[releaselevel.lower()]
|
| 125 |
+
except KeyError:
|
| 126 |
+
raise ValueError('Invalid releaselevel "{0}"'.format(releaselevel))
|
| 127 |
+
if releaselevel == 'final' and serial != 0:
|
| 128 |
+
raise ValueError('final release must not have non-zero serial')
|
| 129 |
+
|
| 130 |
+
return VersionInfo(major, minor, micro, releaselevel, serial)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def get_version(*args, **kwargs):
|
| 134 |
+
'''
|
| 135 |
+
Create a version string suitable for use as `__version__`.
|
| 136 |
+
|
| 137 |
+
Make sure arguments are appropriate, but leave all actual processing and
|
| 138 |
+
value and type checking to `get_version_info()`.
|
| 139 |
+
'''
|
| 140 |
+
usemicro = kwargs.pop('usemicro', True)
|
| 141 |
+
useserial = kwargs.pop('useserial', True)
|
| 142 |
+
if kwargs:
|
| 143 |
+
raise TypeError('Unexpected keyword(s): {0}'.format(', '.join('{0}'.format(k) for k in kwargs)))
|
| 144 |
+
|
| 145 |
+
if len(args) == 1:
|
| 146 |
+
version_info = args[0]
|
| 147 |
+
if not isinstance(version_info, VersionInfo):
|
| 148 |
+
raise TypeError('Positional argument must have 5 components, or be a VersionInfo instance')
|
| 149 |
+
elif len(args) == 5:
|
| 150 |
+
version_info = get_version_info(*args, usemicro=usemicro, useserial=useserial)
|
| 151 |
+
else:
|
| 152 |
+
raise TypeError('Positional argument must have 5 components, or be a VersionInfo instance')
|
| 153 |
+
|
| 154 |
+
version = '{0}.{1}'.format(version_info.major, version_info.minor)
|
| 155 |
+
if usemicro:
|
| 156 |
+
version += '.{0}'.format(version_info.micro)
|
| 157 |
+
if version_info.releaselevel != 'final':
|
| 158 |
+
if version_info.releaselevel in ('dev', 'post'):
|
| 159 |
+
version += '.{0}'.format(version_info.releaselevel)
|
| 160 |
+
else:
|
| 161 |
+
version += '{0}'.format(version_info.releaselevel)
|
| 162 |
+
if useserial:
|
| 163 |
+
version += '{0}'.format(version_info.serial)
|
| 164 |
+
|
| 165 |
+
return version
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def get_version_plus_info(*args, **kwargs):
|
| 169 |
+
'''
|
| 170 |
+
Create a tuple consisting of a version string and a VersionInfo instance.
|
| 171 |
+
'''
|
| 172 |
+
usemicro = kwargs.pop('usemicro', True)
|
| 173 |
+
useserial = kwargs.pop('useserial', True)
|
| 174 |
+
if kwargs:
|
| 175 |
+
raise TypeError('Unexpected keyword(s): {0}'.format(', '.join('{0}'.format(k) for k in kwargs)))
|
| 176 |
+
|
| 177 |
+
version_info = get_version_info(*args, usemicro=usemicro, useserial=useserial)
|
| 178 |
+
version = get_version(version_info, usemicro=usemicro, useserial=useserial)
|
| 179 |
+
return (version, version_info)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# Now that the required functions exist, process `__version_tuple__` into
|
| 185 |
+
# `__version__` and `__version_info__` for the module
|
| 186 |
+
__version__, __version_info__ = get_version_plus_info(*__version_tuple__)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def get_version_from_version_py_str(string):
|
| 193 |
+
'''
|
| 194 |
+
Extract version from version.py that has been read into a string.
|
| 195 |
+
|
| 196 |
+
This assumes a simple, straightforward version.py. It is meant for use
|
| 197 |
+
in setup.py files.
|
| 198 |
+
'''
|
| 199 |
+
if not isinstance(string, str):
|
| 200 |
+
raise TypeError('Argument must be a string')
|
| 201 |
+
if string.count('__version__') != 1:
|
| 202 |
+
raise RuntimeError('Failed to extract version from string')
|
| 203 |
+
exec_locals = {}
|
| 204 |
+
for line in string.splitlines():
|
| 205 |
+
if line.startswith('__version__'):
|
| 206 |
+
if not any(x in line for x in ['get_version(', 'get_version_plus_info(']):
|
| 207 |
+
raise RuntimeError('Failed to extract version from string')
|
| 208 |
+
if 'fmtversion.get_version' in line:
|
| 209 |
+
line = line.replace('fmtversion.get_version', 'get_version')
|
| 210 |
+
try:
|
| 211 |
+
exec(compile(line, 'version.py', 'exec'), globals(), exec_locals)
|
| 212 |
+
except Exception:
|
| 213 |
+
raise RuntimeError('Failed to extract version from string')
|
| 214 |
+
break
|
| 215 |
+
if '__version__' not in exec_locals:
|
| 216 |
+
raise RuntimeError('Failed to extract version from string')
|
| 217 |
+
return exec_locals['__version__']
|
text2qti/gui/__init__.py
ADDED
|
File without changes
|
text2qti/gui/tk.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import pathlib
|
| 13 |
+
import shutil
|
| 14 |
+
import time
|
| 15 |
+
import tkinter as tk
|
| 16 |
+
import tkinter.filedialog
|
| 17 |
+
import webbrowser
|
| 18 |
+
from ..config import Config
|
| 19 |
+
from ..err import Text2qtiError
|
| 20 |
+
from ..qti import QTI
|
| 21 |
+
from ..quiz import Quiz
|
| 22 |
+
from .. import version
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
config = Config()
|
| 28 |
+
config.load()
|
| 29 |
+
file_name = ''
|
| 30 |
+
|
| 31 |
+
window = tk.Tk()
|
| 32 |
+
window.title('text2qti')
|
| 33 |
+
# Bring window to front and put in focus
|
| 34 |
+
window.iconify()
|
| 35 |
+
window.update()
|
| 36 |
+
window.deiconify()
|
| 37 |
+
|
| 38 |
+
# Window grid setup
|
| 39 |
+
current_row = 0
|
| 40 |
+
column_count = 4
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
header_label = tk.Label(
|
| 44 |
+
window,
|
| 45 |
+
text='text2qti – Create quizzes in QTI format from Markdown-based plain text',
|
| 46 |
+
font=(None, 16),
|
| 47 |
+
)
|
| 48 |
+
header_label.grid(
|
| 49 |
+
row=current_row, column=0, columnspan=column_count, padx=(30, 30),
|
| 50 |
+
sticky='nsew',
|
| 51 |
+
)
|
| 52 |
+
current_row += 1
|
| 53 |
+
header_link_label = tk.Label(
|
| 54 |
+
window,
|
| 55 |
+
text='github.com/gpoore/text2qti',
|
| 56 |
+
font=(None, 14), fg='blue', cursor='hand2',
|
| 57 |
+
)
|
| 58 |
+
header_link_label.bind('<Button-1>', lambda x: webbrowser.open_new('https://github.com/gpoore/text2qti'))
|
| 59 |
+
header_link_label.grid(
|
| 60 |
+
row=current_row, column=0, columnspan=column_count, padx=(30, 30),
|
| 61 |
+
sticky='nsew',
|
| 62 |
+
)
|
| 63 |
+
current_row += 1
|
| 64 |
+
version_label = tk.Label(
|
| 65 |
+
window,
|
| 66 |
+
text=f'Version {version.__version__}',
|
| 67 |
+
)
|
| 68 |
+
version_label.grid(
|
| 69 |
+
row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 30),
|
| 70 |
+
sticky='nsew',
|
| 71 |
+
)
|
| 72 |
+
current_row += 1
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
file_browser_label = tk.Label(
|
| 76 |
+
window,
|
| 77 |
+
text='Quiz file:\n(plain text file)',
|
| 78 |
+
justify='right',
|
| 79 |
+
)
|
| 80 |
+
file_browser_label.grid(
|
| 81 |
+
row=current_row, column=0, padx=(30, 5), pady=(5, 25),
|
| 82 |
+
sticky='nse',
|
| 83 |
+
)
|
| 84 |
+
last_dir = None
|
| 85 |
+
def browse_files():
|
| 86 |
+
nonlocal file_name
|
| 87 |
+
nonlocal last_dir
|
| 88 |
+
if last_dir is None:
|
| 89 |
+
initialdir = pathlib.Path('~').expanduser()
|
| 90 |
+
else:
|
| 91 |
+
initialdir = last_dir
|
| 92 |
+
file_name = tkinter.filedialog.askopenfilename(
|
| 93 |
+
initialdir=initialdir,
|
| 94 |
+
title='Select a quiz file',
|
| 95 |
+
filetypes=[('Quiz files', '*.md;*.txt')],
|
| 96 |
+
)
|
| 97 |
+
if file_name:
|
| 98 |
+
if last_dir is None:
|
| 99 |
+
last_dir = pathlib.Path(file_name).parent
|
| 100 |
+
file_browser_button.config(text=f'"{file_name}"', fg='green')
|
| 101 |
+
else:
|
| 102 |
+
file_browser_button.config(text=f'<none selected>', fg='red')
|
| 103 |
+
|
| 104 |
+
file_browser_button = tk.Button(
|
| 105 |
+
window,
|
| 106 |
+
text='<none selected>',
|
| 107 |
+
fg='red',
|
| 108 |
+
command=browse_files,
|
| 109 |
+
)
|
| 110 |
+
file_browser_button.grid(
|
| 111 |
+
row=current_row, column=1, columnspan=column_count-1, padx=(0, 30), pady=(5, 25),
|
| 112 |
+
sticky='nsew',
|
| 113 |
+
)
|
| 114 |
+
current_row += 1
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
advanced_options_label = tk.Label(
|
| 118 |
+
window,
|
| 119 |
+
text='Advanced options – LaTeX math & executable code',
|
| 120 |
+
justify='right',
|
| 121 |
+
)
|
| 122 |
+
advanced_options_label.grid(
|
| 123 |
+
row=current_row, column=1, columnspan=2, padx=(0, 0), pady=(5, 5),
|
| 124 |
+
sticky='nsw',
|
| 125 |
+
)
|
| 126 |
+
current_row += 1
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
latex_url_label = tk.Label(
|
| 130 |
+
window,
|
| 131 |
+
text='LaTeX math rendering URL:\n(for Canvas and similar systems)',
|
| 132 |
+
justify='right',
|
| 133 |
+
)
|
| 134 |
+
latex_url_label.grid(
|
| 135 |
+
row=current_row, column=0, padx=(30, 5), pady=(5, 5),
|
| 136 |
+
sticky='nse',
|
| 137 |
+
)
|
| 138 |
+
latex_url_entry = tk.Entry(window, width=100)
|
| 139 |
+
latex_url_entry.grid(
|
| 140 |
+
row=current_row, column=1, columnspan=column_count-1, padx=(0, 30), pady=(5, 5),
|
| 141 |
+
sticky='nsew',
|
| 142 |
+
)
|
| 143 |
+
if 'latex_render_url' in config:
|
| 144 |
+
latex_url_entry.insert(1, f"{config['latex_render_url']}")
|
| 145 |
+
current_row += 1
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
pandoc_exists = bool(shutil.which('pandoc'))
|
| 149 |
+
pandoc_mathml_label = tk.Label(
|
| 150 |
+
window,
|
| 151 |
+
text='Convert LaTeX math to MathML:\n(requires Pandoc; ignores rendering URL)',
|
| 152 |
+
justify='right',
|
| 153 |
+
)
|
| 154 |
+
if not pandoc_exists:
|
| 155 |
+
pandoc_mathml_label['fg'] = 'gray'
|
| 156 |
+
pandoc_mathml_label.grid(
|
| 157 |
+
row=current_row, column=0, padx=(30, 5), pady=(5, 5),
|
| 158 |
+
sticky='nse',
|
| 159 |
+
)
|
| 160 |
+
pandoc_mathml_bool = tk.BooleanVar()
|
| 161 |
+
def pandoc_mathml_command():
|
| 162 |
+
if pandoc_mathml_bool.get():
|
| 163 |
+
latex_url_label['fg'] = 'gray'
|
| 164 |
+
latex_url_entry['fg'] = 'gray'
|
| 165 |
+
else:
|
| 166 |
+
latex_url_label['fg'] = 'black'
|
| 167 |
+
latex_url_entry['fg'] = 'black'
|
| 168 |
+
if pandoc_exists:
|
| 169 |
+
pandoc_mathml_button = tk.Checkbutton(
|
| 170 |
+
window,
|
| 171 |
+
variable=pandoc_mathml_bool,
|
| 172 |
+
command=pandoc_mathml_command,
|
| 173 |
+
)
|
| 174 |
+
pandoc_mathml_bool.set(config['pandoc_mathml'])
|
| 175 |
+
else:
|
| 176 |
+
pandoc_mathml_button = tk.Checkbutton(
|
| 177 |
+
window,
|
| 178 |
+
state=tk.DISABLED,
|
| 179 |
+
)
|
| 180 |
+
pandoc_mathml_button.grid(
|
| 181 |
+
row=current_row, column=1, sticky='w',
|
| 182 |
+
)
|
| 183 |
+
current_row += 1
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
run_code_blocks_label = tk.Label(
|
| 187 |
+
window,
|
| 188 |
+
text='Allow executable code blocks:\n(only use for trusted code)',
|
| 189 |
+
justify='right',
|
| 190 |
+
)
|
| 191 |
+
run_code_blocks_label.grid(
|
| 192 |
+
row=current_row, column=0, padx=(30, 5), pady=(5, 5),
|
| 193 |
+
sticky='nse',
|
| 194 |
+
)
|
| 195 |
+
run_code_blocks_bool = tk.BooleanVar()
|
| 196 |
+
run_code_blocks_bool.set(config['run_code_blocks'])
|
| 197 |
+
def run_code_blocks_command():
|
| 198 |
+
if run_code_blocks_bool.get():
|
| 199 |
+
run_code_blocks_label['fg'] = 'red'
|
| 200 |
+
else:
|
| 201 |
+
run_code_blocks_label['fg'] = 'black'
|
| 202 |
+
run_code_blocks_button = tk.Checkbutton(
|
| 203 |
+
window,
|
| 204 |
+
variable=run_code_blocks_bool,
|
| 205 |
+
command=run_code_blocks_command,
|
| 206 |
+
)
|
| 207 |
+
run_code_blocks_button.grid(
|
| 208 |
+
row=current_row, column=1, sticky='w',
|
| 209 |
+
)
|
| 210 |
+
current_row += 1
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def run():
|
| 214 |
+
run_message_text.delete(1.0, tk.END)
|
| 215 |
+
run_message_text['fg'] = 'gray'
|
| 216 |
+
run_message_text.insert(tk.INSERT, 'Starting...')
|
| 217 |
+
run_message_text.update()
|
| 218 |
+
error_message = None
|
| 219 |
+
if not file_name:
|
| 220 |
+
error_message = 'Must select a quiz file'
|
| 221 |
+
run_message_text.delete(1.0, tk.END)
|
| 222 |
+
run_message_text.insert(tk.INSERT, error_message)
|
| 223 |
+
run_message_text['fg'] = 'red'
|
| 224 |
+
return
|
| 225 |
+
if latex_url_entry.get():
|
| 226 |
+
config['latex_render_url'] = latex_url_entry.get()
|
| 227 |
+
config['run_code_blocks'] = run_code_blocks_bool.get()
|
| 228 |
+
config['pandoc_mathml'] = pandoc_mathml_bool.get()
|
| 229 |
+
|
| 230 |
+
file_path = pathlib.Path(file_name)
|
| 231 |
+
try:
|
| 232 |
+
text = file_path.read_text(encoding='utf-8-sig') # Handle BOM for Windows
|
| 233 |
+
except FileNotFoundError:
|
| 234 |
+
error_message = f'File "{file_path}" does not exist.'
|
| 235 |
+
except PermissionError as e:
|
| 236 |
+
error_message = f'File "{file_path}" cannot be read due to permission error. Technical details:\n\n{e}'
|
| 237 |
+
except UnicodeDecodeError as e:
|
| 238 |
+
error_message = f'File "{file_path}" is not encoded in valid UTF-8. Technical details:\n\n{e}'
|
| 239 |
+
except Exception as e:
|
| 240 |
+
error_message = f'An error occurred in reading the quiz file. Technical details:\n\n{e}'
|
| 241 |
+
if error_message:
|
| 242 |
+
run_message_text.delete(1.0, tk.END)
|
| 243 |
+
run_message_text.insert(tk.INSERT, error_message)
|
| 244 |
+
run_message_text['fg'] = 'red'
|
| 245 |
+
return
|
| 246 |
+
cwd = pathlib.Path.cwd()
|
| 247 |
+
os.chdir(file_path.parent)
|
| 248 |
+
try:
|
| 249 |
+
quiz = Quiz(text, config=config, source_name=file_path.as_posix())
|
| 250 |
+
qti = QTI(quiz)
|
| 251 |
+
qti.save(f'{file_path.stem}.zip')
|
| 252 |
+
except Text2qtiError as e:
|
| 253 |
+
error_message = f'Quiz creation failed:\n\n{e}'
|
| 254 |
+
except Exception as e:
|
| 255 |
+
error_message = f'Quiz creation failed unexpectedly. Technical details:\n\n{e}'
|
| 256 |
+
finally:
|
| 257 |
+
os.chdir(cwd)
|
| 258 |
+
if error_message:
|
| 259 |
+
run_message_text.delete(1.0, tk.END)
|
| 260 |
+
run_message_text.insert(tk.INSERT, error_message)
|
| 261 |
+
run_message_text['fg'] = 'red'
|
| 262 |
+
else:
|
| 263 |
+
run_message_text.delete(1.0, tk.END)
|
| 264 |
+
run_message_text.insert(tk.INSERT, f'Created quiz "{file_path.parent.as_posix()}/{file_path.stem}.zip"')
|
| 265 |
+
run_message_text['fg'] = 'green'
|
| 266 |
+
run_button = tk.Button(
|
| 267 |
+
window,
|
| 268 |
+
text='RUN',
|
| 269 |
+
font=(None, 14),
|
| 270 |
+
command=run,
|
| 271 |
+
)
|
| 272 |
+
run_button.grid(
|
| 273 |
+
row=current_row, column=1, columnspan=2, padx=(0, 0), pady=(30, 30),
|
| 274 |
+
sticky='nsew',
|
| 275 |
+
)
|
| 276 |
+
current_row += 1
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
run_message_label = tk.Label(
|
| 280 |
+
window,
|
| 281 |
+
text='\nRun Summary:\n',
|
| 282 |
+
relief='ridge',
|
| 283 |
+
width=120,
|
| 284 |
+
)
|
| 285 |
+
run_message_label.grid(
|
| 286 |
+
row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 0),
|
| 287 |
+
sticky='nsew',
|
| 288 |
+
)
|
| 289 |
+
current_row += 1
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
run_message_frame = tk.Frame(
|
| 293 |
+
window,
|
| 294 |
+
width=120, height=40,
|
| 295 |
+
borderwidth=1, relief='sunken', bg='white',
|
| 296 |
+
)
|
| 297 |
+
run_message_frame.grid(
|
| 298 |
+
row=current_row, column=0, columnspan=column_count, padx=(30, 30), pady=(0, 30),
|
| 299 |
+
sticky='nsew',
|
| 300 |
+
)
|
| 301 |
+
run_message_scrollbar = tk.Scrollbar(run_message_frame)
|
| 302 |
+
run_message_scrollbar.pack(
|
| 303 |
+
side='right', fill='y',
|
| 304 |
+
)
|
| 305 |
+
run_message_text = tk.Text(
|
| 306 |
+
run_message_frame,
|
| 307 |
+
width=10, height=10, borderwidth=0, highlightthickness=0,
|
| 308 |
+
wrap='word',
|
| 309 |
+
yscrollcommand=run_message_scrollbar.set,
|
| 310 |
+
)
|
| 311 |
+
run_message_text.insert(tk.INSERT, 'Waiting...')
|
| 312 |
+
run_message_text['fg'] = 'gray'
|
| 313 |
+
run_message_scrollbar.config(command=run_message_text.yview)
|
| 314 |
+
run_message_text.pack(
|
| 315 |
+
side='left', fill='both', expand=True,
|
| 316 |
+
padx=(5, 5), pady=(5, 5),
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
window.mainloop()
|
text2qti/markdown.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import atexit
|
| 12 |
+
import hashlib
|
| 13 |
+
import json
|
| 14 |
+
import pathlib
|
| 15 |
+
import platform
|
| 16 |
+
import re
|
| 17 |
+
import subprocess
|
| 18 |
+
import time
|
| 19 |
+
import typing
|
| 20 |
+
from typing import Dict, Optional, Set
|
| 21 |
+
import urllib.parse
|
| 22 |
+
import zipfile
|
| 23 |
+
|
| 24 |
+
import markdown
|
| 25 |
+
# Markdown extensions are imported and initialized explicitly to ensure that
|
| 26 |
+
# pyinstaller identifies them.
|
| 27 |
+
import markdown.extensions
|
| 28 |
+
import markdown.extensions.smarty
|
| 29 |
+
import markdown.extensions.sane_lists
|
| 30 |
+
import markdown.extensions.def_list
|
| 31 |
+
import markdown.extensions.fenced_code
|
| 32 |
+
import markdown.extensions.footnotes
|
| 33 |
+
import markdown.extensions.tables
|
| 34 |
+
import markdown.extensions.md_in_html
|
| 35 |
+
from markdown.inlinepatterns import ImageInlineProcessor, IMAGE_LINK_RE
|
| 36 |
+
|
| 37 |
+
from .config import Config
|
| 38 |
+
from .err import Text2qtiError
|
| 39 |
+
from .version import __version__ as version
|
| 40 |
+
from . import pymd_pandoc_attr
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
md_extensions = [
|
| 44 |
+
markdown.extensions.smarty.makeExtension(),
|
| 45 |
+
markdown.extensions.sane_lists.makeExtension(),
|
| 46 |
+
markdown.extensions.def_list.makeExtension(),
|
| 47 |
+
markdown.extensions.fenced_code.makeExtension(),
|
| 48 |
+
markdown.extensions.footnotes.makeExtension(),
|
| 49 |
+
markdown.extensions.tables.makeExtension(),
|
| 50 |
+
markdown.extensions.md_in_html.makeExtension(),
|
| 51 |
+
pymd_pandoc_attr.makeExtension(),
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class Image(object):
|
| 58 |
+
'''
|
| 59 |
+
Raw image data for quiz insertion.
|
| 60 |
+
'''
|
| 61 |
+
def __init__(self, name: str, data: bytes):
|
| 62 |
+
self.name = name
|
| 63 |
+
self.data = data
|
| 64 |
+
h = hashlib.blake2b()
|
| 65 |
+
h.update(data)
|
| 66 |
+
self.id = h.hexdigest()[:64]
|
| 67 |
+
|
| 68 |
+
@property
|
| 69 |
+
def src_path(self):
|
| 70 |
+
return f'%24IMS-CC-FILEBASE%24/images/{urllib.parse.quote(self.name)}'
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def qti_zip_path(self):
|
| 74 |
+
return f'images/{self.name}'
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def qti_xml_path(self):
|
| 78 |
+
return f'images/{urllib.parse.quote(self.name)}'
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class Text2qtiImagePattern(ImageInlineProcessor):
|
| 84 |
+
'''
|
| 85 |
+
Custom image processor for Python-Markdown that modifies local image
|
| 86 |
+
paths to their final QTI form and also accumulates all image data for QTI
|
| 87 |
+
inclusion.
|
| 88 |
+
'''
|
| 89 |
+
def __init__(self, pattern_re, markdown_md, text2qti_md):
|
| 90 |
+
super().__init__(pattern_re, markdown_md)
|
| 91 |
+
self.text2qti_md = text2qti_md
|
| 92 |
+
|
| 93 |
+
def handleMatch(self, match, data):
|
| 94 |
+
node, start, end = super().handleMatch(match, data)
|
| 95 |
+
src = node.attrib.get('src')
|
| 96 |
+
if src and not any(src.startswith(x) for x in ('http://', 'https://')):
|
| 97 |
+
src_path = pathlib.Path(src).expanduser()
|
| 98 |
+
try:
|
| 99 |
+
data = src_path.read_bytes()
|
| 100 |
+
except FileNotFoundError:
|
| 101 |
+
raise Text2qtiError(f'File "{src_path}" does not exist')
|
| 102 |
+
except PermissionError as e:
|
| 103 |
+
raise Text2qtiError(f'File "{src_path}" cannot be read due to permission error:\n{e}')
|
| 104 |
+
image = Image(src_path.name, data)
|
| 105 |
+
if image.id in self.text2qti_md.images:
|
| 106 |
+
image = self.text2qti_md.images[image.id]
|
| 107 |
+
else:
|
| 108 |
+
if image.name in self.text2qti_md.image_name_set:
|
| 109 |
+
n = 8
|
| 110 |
+
while image.name in self.text2qti_md.image_name_set:
|
| 111 |
+
image.name = f'{src_path.stem}_{image.id[:n]}{src_path.suffix}'
|
| 112 |
+
n *= 2
|
| 113 |
+
if n >= len(image.id)*2:
|
| 114 |
+
raise Text2qtiError('Hash collision occurred during image deduplication')
|
| 115 |
+
self.text2qti_md.image_name_set.add(image.name)
|
| 116 |
+
self.text2qti_md.images[image.id] = image
|
| 117 |
+
node.attrib['src'] = image.src_path
|
| 118 |
+
return node, start, end
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class Markdown(object):
|
| 124 |
+
r'''
|
| 125 |
+
Convert text from Markdown to HTML. Then escape the HTML for insertion
|
| 126 |
+
into XML templates.
|
| 127 |
+
|
| 128 |
+
During the Markdown to HTML conversion, LaTeX math is converted to Canvas
|
| 129 |
+
img tags. A subset of siunitx (https://ctan.org/pkg/siunitx) LaTeX macros
|
| 130 |
+
are also supported, with limited features: `\SI`, `\si`, and `\num`.
|
| 131 |
+
siunitx macros are extracted via regex and then converted into plain
|
| 132 |
+
LaTeX, since Canvas LaTeX support does not cover siunitx.
|
| 133 |
+
'''
|
| 134 |
+
def __init__(self, config: Optional[Config]=None):
|
| 135 |
+
self.config = config
|
| 136 |
+
|
| 137 |
+
markdown_processor = markdown.Markdown(extensions=md_extensions)
|
| 138 |
+
markdown_image_processor = Text2qtiImagePattern(IMAGE_LINK_RE, markdown_processor, self)
|
| 139 |
+
markdown_processor.inlinePatterns.register(markdown_image_processor, 'image_link', 150)
|
| 140 |
+
self.markdown_processor = markdown_processor
|
| 141 |
+
|
| 142 |
+
self.images: Dict[str, Image] = {}
|
| 143 |
+
self.image_name_set: Set[str] = set()
|
| 144 |
+
|
| 145 |
+
if config is None:
|
| 146 |
+
self.latex_to_qti = self._latex_to_qti_unconfigured
|
| 147 |
+
elif config['pandoc_mathml']:
|
| 148 |
+
self.latex_to_qti = self.latex_to_pandoc_mathml
|
| 149 |
+
self._prep_cache()
|
| 150 |
+
else:
|
| 151 |
+
self.latex_to_qti = self.latex_to_canvas_img
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def finalize(self):
|
| 155 |
+
if self.config is not None and self.config['pandoc_mathml']:
|
| 156 |
+
self._save_cache()
|
| 157 |
+
self._cache_lock_path.unlink()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _latex_to_qti_unconfigured(self, latex: str):
|
| 161 |
+
raise Text2qtiError('Cannot convert LaTeX to QTI unless Markdown configuration is provided')
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _prep_cache(self):
|
| 165 |
+
self._cache_path = pathlib.Path('_text2qti_cache.zip')
|
| 166 |
+
self._cache_lock_path = pathlib.Path('_text2qti_cache.lock')
|
| 167 |
+
|
| 168 |
+
max_lock_wait = 2
|
| 169 |
+
lock_check_interval = 0.1
|
| 170 |
+
lock_time = 0
|
| 171 |
+
while True:
|
| 172 |
+
try:
|
| 173 |
+
self._cache_lock_path.touch(exist_ok=False)
|
| 174 |
+
except FileExistsError:
|
| 175 |
+
if lock_time > max_lock_wait:
|
| 176 |
+
raise Text2qtiError('The text2qti cache is locked; this usually means that another instance of '
|
| 177 |
+
'text2qti is already running and you should try again later')
|
| 178 |
+
time.sleep(lock_check_interval)
|
| 179 |
+
lock_time += lock_check_interval
|
| 180 |
+
else:
|
| 181 |
+
break
|
| 182 |
+
def final_cache_cleanup():
|
| 183 |
+
try:
|
| 184 |
+
self._cache_lock_path.unlink()
|
| 185 |
+
except FileNotFoundError:
|
| 186 |
+
pass
|
| 187 |
+
atexit.register(final_cache_cleanup)
|
| 188 |
+
|
| 189 |
+
default_cache = {
|
| 190 |
+
'version': version,
|
| 191 |
+
'pandoc_mathml': {}
|
| 192 |
+
}
|
| 193 |
+
try:
|
| 194 |
+
with zipfile.ZipFile(str(self._cache_path)) as zf:
|
| 195 |
+
with zf.open('cache.json') as f:
|
| 196 |
+
cache = json.load(f)
|
| 197 |
+
except (FileNotFoundError, KeyError, json.JSONDecodeError):
|
| 198 |
+
cache = default_cache
|
| 199 |
+
else:
|
| 200 |
+
if not isinstance(cache, dict) or cache.get('version') != version:
|
| 201 |
+
cache = default_cache
|
| 202 |
+
for v in cache['pandoc_mathml'].values():
|
| 203 |
+
v['unused_count'] += 1
|
| 204 |
+
self._cache = cache
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _save_cache(self):
|
| 208 |
+
self._cache['pandoc_mathml'] = {k: v for k, v in self._cache['pandoc_mathml'].items()
|
| 209 |
+
if v['unused_count'] <= 10}
|
| 210 |
+
with zipfile.ZipFile(str(self._cache_path), 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
| 211 |
+
zf.writestr('cache.json', json.dumps(self._cache))
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
XML_ESCAPES = (('&', '&'),
|
| 215 |
+
('<', '<'),
|
| 216 |
+
('>', '>'),
|
| 217 |
+
('"', '"'),
|
| 218 |
+
("'", '''))
|
| 219 |
+
XML_ESCAPES_LESS_QUOTES = tuple(x for x in XML_ESCAPES if x[0] not in ("'", '"'))
|
| 220 |
+
XML_ESCAPES_LESS_SQUOTE = tuple(x for x in XML_ESCAPES if x[0] != "'")
|
| 221 |
+
XML_ESCAPES_LESS_DQUOTE = tuple(x for x in XML_ESCAPES if x[0] != '"')
|
| 222 |
+
|
| 223 |
+
def xml_escape(self, string: str, *, squotes: bool=True, dquotes: bool=True) -> str:
|
| 224 |
+
'''
|
| 225 |
+
Escape a string for XML insertion, with options not to escape quotes.
|
| 226 |
+
'''
|
| 227 |
+
if squotes and dquotes:
|
| 228 |
+
escapes = self.XML_ESCAPES
|
| 229 |
+
elif squotes:
|
| 230 |
+
escapes = self.XML_ESCAPES_LESS_DQUOTE
|
| 231 |
+
elif dquotes:
|
| 232 |
+
escapes = self.XML_ESCAPES_LESS_SQUOTE
|
| 233 |
+
else:
|
| 234 |
+
escapes = self.XML_ESCAPES_LESS_QUOTES
|
| 235 |
+
for char, esc in escapes:
|
| 236 |
+
string = string.replace(char, esc)
|
| 237 |
+
return string
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
CANVAS_EQUATION_TEMPLATE = '<img class="equation_image" title="{latex_xml_escaped}" src="{latex_render_url}/{latex_url_escaped}" alt="LaTeX: {latex_xml_escaped}" data-equation-content="{latex_xml_escaped}">'
|
| 241 |
+
|
| 242 |
+
def latex_to_canvas_img(self, latex: str) -> str:
|
| 243 |
+
'''
|
| 244 |
+
Convert a LaTeX equation into an img tag suitable for Canvas.
|
| 245 |
+
|
| 246 |
+
Requires an institutional LaTeX equation rendering URL. The URL is stored
|
| 247 |
+
in the text2qti config file or can be passed with flag --latex-render-url.
|
| 248 |
+
It will typically be of the form
|
| 249 |
+
|
| 250 |
+
https://<institution>.instructure.com/equation_images/
|
| 251 |
+
|
| 252 |
+
or
|
| 253 |
+
|
| 254 |
+
https://canvas.<institution>.edu/equation_images/
|
| 255 |
+
'''
|
| 256 |
+
latex_render_url = self.config['latex_render_url'].rstrip('/')
|
| 257 |
+
latex_xml_escaped = self.xml_escape(latex)
|
| 258 |
+
# Double url escaping is required
|
| 259 |
+
latex_url_escaped = urllib.parse.quote(urllib.parse.quote(latex))
|
| 260 |
+
return self.CANVAS_EQUATION_TEMPLATE.format(latex_render_url=latex_render_url,
|
| 261 |
+
latex_xml_escaped=latex_xml_escaped,
|
| 262 |
+
latex_url_escaped=latex_url_escaped)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def latex_to_pandoc_mathml(self, latex: str) -> str:
|
| 266 |
+
'''
|
| 267 |
+
Convert a LaTeX equation into MathML using Pandoc.
|
| 268 |
+
'''
|
| 269 |
+
data = self._cache['pandoc_mathml'].get(latex)
|
| 270 |
+
if data is not None:
|
| 271 |
+
mathml = data['mathml']
|
| 272 |
+
data['unused_count'] = 0
|
| 273 |
+
else:
|
| 274 |
+
if platform.system() == 'Windows':
|
| 275 |
+
# Prevent console from appearing for an instant
|
| 276 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 277 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 278 |
+
else:
|
| 279 |
+
startupinfo = None
|
| 280 |
+
try:
|
| 281 |
+
proc = subprocess.run(['pandoc', '-f', 'markdown', '-t', 'html', '--mathml'],
|
| 282 |
+
input='${0}$'.format(latex), encoding='utf8',
|
| 283 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
| 284 |
+
startupinfo=startupinfo,
|
| 285 |
+
check=True)
|
| 286 |
+
except FileNotFoundError as e:
|
| 287 |
+
raise Text2qtiError(f'Could not find Pandoc:\n{e}')
|
| 288 |
+
except subprocess.CalledProcessError as e:
|
| 289 |
+
raise Text2qtiError(f'Running Pandoc failed:\n{e}')
|
| 290 |
+
mathml = proc.stdout.strip()
|
| 291 |
+
if mathml.startswith('<p>'):
|
| 292 |
+
mathml = mathml[len('<p>'):]
|
| 293 |
+
if mathml.endswith('</p>'):
|
| 294 |
+
mathml = mathml[:-len('</p>')]
|
| 295 |
+
self._cache['pandoc_mathml'][latex] = {
|
| 296 |
+
'mathml': mathml,
|
| 297 |
+
'unused_count': 0,
|
| 298 |
+
}
|
| 299 |
+
return mathml
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
siunitx_num_number_re = re.compile(r'[+-]?(?:0|(?:[1-9][0-9]*(?:\.[0-9]+)?|0?\.[0-9]+)(?:[eE][+-]?(?:[1-9][0-9]*|0+[1-9][0-9]*))?)$')
|
| 303 |
+
|
| 304 |
+
def siunitx_num_to_plain_latex(self, number: str, in_math: bool=False) -> str:
|
| 305 |
+
r'''
|
| 306 |
+
Convert a basic subset of siunitx \num{<number>} syntax into plain LaTeX.
|
| 307 |
+
If `in_math` is true, covert the plain LaTeX to a Canvas img tag.
|
| 308 |
+
'''
|
| 309 |
+
number = number.strip()
|
| 310 |
+
if number.startswith('.'):
|
| 311 |
+
number = f'0{number}'
|
| 312 |
+
if not self.siunitx_num_number_re.match(number):
|
| 313 |
+
raise Text2qtiError(f'Invalid or unsupported LaTeX number "{number}"')
|
| 314 |
+
number = number.lower()
|
| 315 |
+
if 'e' in number:
|
| 316 |
+
significand, magnitude = number.split('e', 1)
|
| 317 |
+
magnitude = magnitude.lstrip('+').lstrip('0')
|
| 318 |
+
if magnitude.startswith('-0'):
|
| 319 |
+
magnitude = magnitude[0] + magnitude[1:].lstrip('0')
|
| 320 |
+
latex_number = f'{significand}\\times 10^{{{magnitude}}}'
|
| 321 |
+
else:
|
| 322 |
+
latex_number = number
|
| 323 |
+
if in_math:
|
| 324 |
+
return latex_number
|
| 325 |
+
return self.latex_to_qti(latex_number)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def siunitx_si_to_plain_latex(self, unit: str, in_math: bool=False) -> str:
|
| 329 |
+
r'''
|
| 330 |
+
Convert a basic subset of siunitx \si{<unit>} syntax into plain LaTeX.
|
| 331 |
+
If `in_math` is true, covert the plain LaTeX to a Canvas img tag.
|
| 332 |
+
'''
|
| 333 |
+
unit = unit.strip()
|
| 334 |
+
unit_list = []
|
| 335 |
+
unit_iter = iter(unit)
|
| 336 |
+
char = next(unit_iter, '')
|
| 337 |
+
while True:
|
| 338 |
+
if char == '' or char == ' ':
|
| 339 |
+
pass
|
| 340 |
+
elif char == '.':
|
| 341 |
+
unit_list.append(r'\!\cdot\!') # Alternative: r'\,'
|
| 342 |
+
elif char == '^':
|
| 343 |
+
char = next(unit_iter, '')
|
| 344 |
+
if char.isdigit():
|
| 345 |
+
unit_list.append(f'^{{{char}}}')
|
| 346 |
+
elif char == '\\':
|
| 347 |
+
unit_list.append('^')
|
| 348 |
+
continue
|
| 349 |
+
else:
|
| 350 |
+
raise Text2qtiError(f'Invalid or unsupported LaTeX unit "{unit}"')
|
| 351 |
+
elif char == '/':
|
| 352 |
+
unit_list.append(r'/') # Alternative: r'\big/'
|
| 353 |
+
elif char == '\\':
|
| 354 |
+
macro = char
|
| 355 |
+
char = next(unit_iter, '')
|
| 356 |
+
while char.isalpha():
|
| 357 |
+
macro += char
|
| 358 |
+
char = next(unit_iter, '')
|
| 359 |
+
if macro == r'\degree':
|
| 360 |
+
unit_list.append(r'^\circ')
|
| 361 |
+
elif macro == r'\celsius':
|
| 362 |
+
unit_list.append(r'^\circ\textrm{C}')
|
| 363 |
+
elif macro == r'\fahrenheit':
|
| 364 |
+
unit_list.append(r'^\circ\textrm{F}')
|
| 365 |
+
elif macro == r'\ohm':
|
| 366 |
+
unit_list.append(r'\Omega')
|
| 367 |
+
elif macro == r'\micro':
|
| 368 |
+
# Ideally, this would be an upright rather than slanted mu
|
| 369 |
+
unit_list.append(r'\mu')
|
| 370 |
+
else:
|
| 371 |
+
unit_list.append(macro)
|
| 372 |
+
continue
|
| 373 |
+
elif char.isalpha():
|
| 374 |
+
unit_list.append(r'\text{')
|
| 375 |
+
unit_list.append(char)
|
| 376 |
+
char = next(unit_iter, '')
|
| 377 |
+
while char.isalpha():
|
| 378 |
+
unit_list.append(char)
|
| 379 |
+
char = next(unit_iter, '')
|
| 380 |
+
unit_list.append('}')
|
| 381 |
+
continue
|
| 382 |
+
else:
|
| 383 |
+
raise Text2qtiError(f'Invalid or unsupported LaTeX unit "{unit}"')
|
| 384 |
+
try:
|
| 385 |
+
char = next(unit_iter)
|
| 386 |
+
except StopIteration:
|
| 387 |
+
break
|
| 388 |
+
latex_unit = '{' + ''.join(unit_list) + '}' # wrapping {} may prevent line breaks
|
| 389 |
+
if in_math:
|
| 390 |
+
return latex_unit
|
| 391 |
+
return self.latex_to_qti(latex_unit)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def siunitx_SI_to_plain_latex(self, number: str, unit: str, in_math: bool=False) -> str:
|
| 395 |
+
r'''
|
| 396 |
+
Convert a basic subset of siunitx \SI{<number>}{<unit>} syntax into plain
|
| 397 |
+
LaTeX. If `in_math` is true, covert the plain LaTeX to a Canvas img tag.
|
| 398 |
+
'''
|
| 399 |
+
latex_number = self.siunitx_num_to_plain_latex(number, in_math=True)
|
| 400 |
+
latex_unit = self.siunitx_si_to_plain_latex(unit, in_math=True)
|
| 401 |
+
if latex_unit.startswith(r'^\circ'):
|
| 402 |
+
unit_sep = ''
|
| 403 |
+
else:
|
| 404 |
+
unit_sep = r'\,' # Alternative: `\>`
|
| 405 |
+
latex = f'{latex_number}{unit_sep}{latex_unit}'
|
| 406 |
+
if in_math:
|
| 407 |
+
return latex
|
| 408 |
+
return self.latex_to_qti(latex)
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
siunitx_num_macro_pattern = r'\\num\{(?P<num_number>[^{}]+)\}'
|
| 412 |
+
siunitx_si_macro_pattern = r'\\si\{(?P<si_unit>[^{}]+)\}'
|
| 413 |
+
siunitx_SI_macro_pattern = r'\\SI\{(?P<SI_number>[^{}]+)\}\{(?P<SI_unit>[^{}]+)\}'
|
| 414 |
+
siunitx_latex_macros_pattern = '|'.join([siunitx_num_macro_pattern, siunitx_si_macro_pattern, siunitx_SI_macro_pattern])
|
| 415 |
+
siunitx_latex_macros_re = re.compile(siunitx_latex_macros_pattern)
|
| 416 |
+
|
| 417 |
+
def _siunitx_dispatch(self, match: typing.Match[str], in_math: bool) -> str:
|
| 418 |
+
'''
|
| 419 |
+
Convert an siunitx regex match to plain LaTeX. If `in_math` is true,
|
| 420 |
+
covert the plain LaTeX to a Canvas img tag.
|
| 421 |
+
'''
|
| 422 |
+
lastgroup = match.lastgroup
|
| 423 |
+
if lastgroup == 'SI_unit':
|
| 424 |
+
return self.siunitx_SI_to_plain_latex(match.group('SI_number'), match.group('SI_unit'), in_math)
|
| 425 |
+
if lastgroup == 'num_number':
|
| 426 |
+
return self.siunitx_num_to_plain_latex(match.group('num_number'), in_math)
|
| 427 |
+
if lastgroup == 'si_unit':
|
| 428 |
+
return self.siunitx_si_to_plain_latex(match.group('si_unit'), in_math)
|
| 429 |
+
raise ValueError
|
| 430 |
+
|
| 431 |
+
def sub_siunitx_to_plain_latex(self, string: str, in_math: bool=False) -> str:
|
| 432 |
+
'''
|
| 433 |
+
Convert all siunitx macros in a string to plain LaTeX. If `in_math` is
|
| 434 |
+
true, covert the plain LaTeX to a Canvas img tag.
|
| 435 |
+
'''
|
| 436 |
+
return self.siunitx_latex_macros_re.sub(lambda match: self._siunitx_dispatch(match, in_math), string)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
escape = r'(?P<escape>\\\$)'
|
| 440 |
+
skip = r'(?P<skip>\\.|\\\n|\$\$+(?!\$))'
|
| 441 |
+
html_comment_pattern = r'(?P<html_comment><!--(?:.|\n)*?-->)'
|
| 442 |
+
block_code_pattern = (
|
| 443 |
+
r'^(?P<block_code>'
|
| 444 |
+
r'(?P<indent>[ \t]*)(?P<block_code_delim>```+(?!`)|~~~+(?!~)).*?\n'
|
| 445 |
+
r'(?:[ \t]*\n|(?P=indent).*\n)*?'
|
| 446 |
+
r'(?P=indent)(?P=block_code_delim)[ \t]*(?:\n|$)'
|
| 447 |
+
r')'
|
| 448 |
+
)
|
| 449 |
+
inline_code_pattern = (
|
| 450 |
+
r'(?P<inline_code>'
|
| 451 |
+
r'(?P<inline_code_delim>`+(?!`))'
|
| 452 |
+
r'(?:.|\n[ \t]*(?![ \t\n]))+?'
|
| 453 |
+
r'(?<!`)(?P=inline_code_delim)(?!`)'
|
| 454 |
+
r')'
|
| 455 |
+
)
|
| 456 |
+
inline_math_pattern = (
|
| 457 |
+
r'\$(?=[^ \t\n])'
|
| 458 |
+
r'(?P<math>(?:[^$\n\\]|\\.|\\?\n[ \t]*(?:[^ \t\n$]))+)'
|
| 459 |
+
r'(?<![ \t\n])\$(?!\$)'
|
| 460 |
+
)
|
| 461 |
+
patterns = '|'.join([
|
| 462 |
+
block_code_pattern,
|
| 463 |
+
siunitx_latex_macros_pattern,
|
| 464 |
+
escape,
|
| 465 |
+
skip,
|
| 466 |
+
html_comment_pattern,
|
| 467 |
+
inline_code_pattern,
|
| 468 |
+
inline_math_pattern,
|
| 469 |
+
])
|
| 470 |
+
skip_or_html_comment_or_code_math_siunitx_re = re.compile(patterns, re.MULTILINE)
|
| 471 |
+
|
| 472 |
+
def _html_comment_or_inline_code_math_siunitx_dispatch(self, match: typing.Match[str]) -> str:
|
| 473 |
+
'''
|
| 474 |
+
Process LaTeX math and siunitx regex matches into Canvas image tags,
|
| 475 |
+
while stripping HTML comments and leaving things like backslash
|
| 476 |
+
escapes and code unchanged.
|
| 477 |
+
'''
|
| 478 |
+
lastgroup = match.lastgroup
|
| 479 |
+
if lastgroup == 'html_comment':
|
| 480 |
+
return ''
|
| 481 |
+
if lastgroup == 'escape':
|
| 482 |
+
return match.group('escape')[1:]
|
| 483 |
+
if lastgroup == 'skip':
|
| 484 |
+
return match.group('skip')
|
| 485 |
+
if lastgroup == 'block_code':
|
| 486 |
+
return match.group('block_code')
|
| 487 |
+
if lastgroup == 'inline_code':
|
| 488 |
+
return match.group('inline_code')
|
| 489 |
+
if lastgroup == 'math':
|
| 490 |
+
math = match.group('math')
|
| 491 |
+
math = math.replace('\n ', ' ').replace('\n', ' ')
|
| 492 |
+
math = self.sub_siunitx_to_plain_latex(math, in_math=True)
|
| 493 |
+
return self.latex_to_qti(math)
|
| 494 |
+
if lastgroup == 'SI_unit':
|
| 495 |
+
return self.siunitx_SI_to_plain_latex(match.group('SI_number'), match.group('SI_unit'), in_math=False)
|
| 496 |
+
if lastgroup == 'num_number':
|
| 497 |
+
return self.siunitx_num_to_plain_latex(match.group('num_number'), in_math=False)
|
| 498 |
+
if lastgroup == 'si_unit':
|
| 499 |
+
return self.siunitx_si_to_plain_latex(match.group('si_unit'), in_math=False)
|
| 500 |
+
raise ValueError
|
| 501 |
+
|
| 502 |
+
def sub_math_siunitx_to_canvas_img(self, string: str) -> str:
|
| 503 |
+
'''
|
| 504 |
+
Convert all siunitx macros in a string into plain LaTeX. Then convert
|
| 505 |
+
this LaTeX and all $-delimited LaTeX into Canvas img tags.
|
| 506 |
+
'''
|
| 507 |
+
return self.skip_or_html_comment_or_code_math_siunitx_re.sub(self._html_comment_or_inline_code_math_siunitx_dispatch, string)
|
| 508 |
+
|
| 509 |
+
def md_to_html_xml(self, markdown_string: str, strip_p_tags: bool=False) -> str:
|
| 510 |
+
'''
|
| 511 |
+
Convert the Markdown in a string to HTML, then escape the HTML for
|
| 512 |
+
embedding in XML.
|
| 513 |
+
'''
|
| 514 |
+
markdown_string_processed_latex = self.sub_math_siunitx_to_canvas_img(markdown_string)
|
| 515 |
+
try:
|
| 516 |
+
html = self.markdown_processor.reset().convert(markdown_string_processed_latex)
|
| 517 |
+
except Exception as e:
|
| 518 |
+
raise Text2qtiError(f'Conversion from Markdown to HTML failed:\n{e}')
|
| 519 |
+
if strip_p_tags:
|
| 520 |
+
if html.startswith('<p>'):
|
| 521 |
+
html = html[3:]
|
| 522 |
+
if html.endswith('</p>'):
|
| 523 |
+
html = html[:-4]
|
| 524 |
+
xml = self.xml_escape(html, squotes=False, dquotes=False)
|
| 525 |
+
return xml
|
| 526 |
+
|
| 527 |
+
def _md_to_pandoc_dispatch(self, match: typing.Match[str],
|
| 528 |
+
_passthrough=set(['escape', 'skip', 'block_code', 'inline_code'])) -> str:
|
| 529 |
+
'''
|
| 530 |
+
Process LaTeX math and siunitx regex matches into Pandoc Markdown,
|
| 531 |
+
while stripping HTML comments and leaving things like backslash
|
| 532 |
+
escapes and code unchanged.
|
| 533 |
+
'''
|
| 534 |
+
lastgroup = match.lastgroup
|
| 535 |
+
if lastgroup == 'html_comment':
|
| 536 |
+
return ''
|
| 537 |
+
if lastgroup in _passthrough:
|
| 538 |
+
return match.group(lastgroup)
|
| 539 |
+
if lastgroup == 'math':
|
| 540 |
+
math = match.group('math')
|
| 541 |
+
math = math.replace('\n ', ' ').replace('\n', ' ')
|
| 542 |
+
math = self.sub_siunitx_to_plain_latex(math, in_math=True)
|
| 543 |
+
return '${0}$'.format(math)
|
| 544 |
+
if lastgroup == 'SI_unit':
|
| 545 |
+
return '${0}$'.format(self.siunitx_SI_to_plain_latex(match.group('SI_number'), match.group('SI_unit'), in_math=True))
|
| 546 |
+
if lastgroup == 'num_number':
|
| 547 |
+
return '${0}$'.format(self.siunitx_num_to_plain_latex(match.group('num_number'), in_math=True))
|
| 548 |
+
if lastgroup == 'si_unit':
|
| 549 |
+
return '${0}$'.format(self.siunitx_si_to_plain_latex(match.group('si_unit'), in_math=True))
|
| 550 |
+
raise ValueError
|
| 551 |
+
|
| 552 |
+
def md_to_pandoc(self, string: str) -> str:
|
| 553 |
+
'''
|
| 554 |
+
Convert Markdown from a quiz into a form suitable for Pandoc Markdown.
|
| 555 |
+
|
| 556 |
+
Convert all siunitx macros in a string into plain LaTeX guaranteed to
|
| 557 |
+
be wrapped in `$`. This can be processed into multiple formats by
|
| 558 |
+
Pandoc.
|
| 559 |
+
'''
|
| 560 |
+
return self.skip_or_html_comment_or_code_math_siunitx_re.sub(self._md_to_pandoc_dispatch, string)
|
text2qti/pymd_pandoc_attr.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2021, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
'''
|
| 12 |
+
Pandoc-style attribute syntax for Python-Markdown.
|
| 13 |
+
Attribute support is currently limited to images.
|
| 14 |
+
See https://pandoc.org/MANUAL.html#images.
|
| 15 |
+
|
| 16 |
+
Inspired by https://github.com/Python-Markdown/markdown/blob/master/markdown/extensions/attr_list.py.
|
| 17 |
+
'''
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
from markdown.extensions import Extension
|
| 21 |
+
from markdown.treeprocessors import Treeprocessor
|
| 22 |
+
from typing import List, Tuple
|
| 23 |
+
import re
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
IDENTIFIER_PATTERN = r'[A-Za-z][0-9A-Za-z_\-]+'
|
| 27 |
+
ID_PATTERN = rf'#{IDENTIFIER_PATTERN}'
|
| 28 |
+
CLASS_PATTERN = rf'\.{IDENTIFIER_PATTERN}'
|
| 29 |
+
KV_PATTERN = rf'{IDENTIFIER_PATTERN}=[0-9A-Za-z_\-%]+'
|
| 30 |
+
ATTR_PATTERN = (
|
| 31 |
+
r'\{[ ]*(?!\})('
|
| 32 |
+
rf'(?:{ID_PATTERN}(?=[ \}}]))?'
|
| 33 |
+
rf'(?:{CLASS_PATTERN}(?=[ \}}])|{KV_PATTERN}(?=[ \}}])|[ ]+(?=[^ \}}]))*'
|
| 34 |
+
r')[ ]*\}'
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
def _handle_id(scanner: re.Scanner, token: str) -> Tuple[str, str]:
|
| 38 |
+
return '#', token[1:]
|
| 39 |
+
|
| 40 |
+
def _handle_class(scanner: re.Scanner, token: str) -> Tuple[str, str]:
|
| 41 |
+
return '.', token[1:]
|
| 42 |
+
|
| 43 |
+
def _handle_key_value(scanner: re.Scanner, token: str) -> Tuple[str, str]:
|
| 44 |
+
return token.split('=', 1)
|
| 45 |
+
|
| 46 |
+
_scanner = re.Scanner([
|
| 47 |
+
(ID_PATTERN, _handle_id),
|
| 48 |
+
(CLASS_PATTERN, _handle_class),
|
| 49 |
+
(KV_PATTERN, _handle_key_value),
|
| 50 |
+
(r'[ ]+', None),
|
| 51 |
+
])
|
| 52 |
+
|
| 53 |
+
def get_attrs(string: str) -> List[Tuple[str, str]]:
|
| 54 |
+
'''
|
| 55 |
+
Parse a string of attributes `<attrs>` that has already been extracted
|
| 56 |
+
from a string of the form `{<attrs>}`. Return a list of attribute tuples
|
| 57 |
+
of the form `(<key>, <value>)`.
|
| 58 |
+
'''
|
| 59 |
+
results, remainder = _scanner.scan(string)
|
| 60 |
+
return results
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class PandocAttrTreeprocessor(Treeprocessor):
|
| 64 |
+
ATTR_RE = re.compile(ATTR_PATTERN)
|
| 65 |
+
def run(self, doc):
|
| 66 |
+
for elem in doc.iter():
|
| 67 |
+
if self.md.is_block_level(elem.tag):
|
| 68 |
+
pass
|
| 69 |
+
else:
|
| 70 |
+
# inline, only for images currently
|
| 71 |
+
if elem.tag == 'img' and elem.tail and elem.tail.startswith('{'):
|
| 72 |
+
match = self.ATTR_RE.match(elem.tail)
|
| 73 |
+
if match:
|
| 74 |
+
self.assign_attrs(elem, match.group(1))
|
| 75 |
+
elem.tail = elem.tail[match.end():]
|
| 76 |
+
|
| 77 |
+
def assign_attrs(self, elem, attrs):
|
| 78 |
+
'''
|
| 79 |
+
Assign attrs to element.
|
| 80 |
+
'''
|
| 81 |
+
for k, v in get_attrs(attrs):
|
| 82 |
+
if k == '#':
|
| 83 |
+
elem.set('id', v)
|
| 84 |
+
elif k == '.':
|
| 85 |
+
elem_class = elem.get('class')
|
| 86 |
+
if elem_class:
|
| 87 |
+
elem.set('class', f'{elem_class} {v}')
|
| 88 |
+
else:
|
| 89 |
+
elem.set('class', v)
|
| 90 |
+
else:
|
| 91 |
+
elem_style = elem.get('style')
|
| 92 |
+
if elem_style:
|
| 93 |
+
elem.set('style', f'{elem_style} {k}:{v};')
|
| 94 |
+
else:
|
| 95 |
+
elem.set('style', f'{k}:{v};')
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class PandocAttrExtension(Extension):
|
| 99 |
+
def extendMarkdown(self, md):
|
| 100 |
+
md.treeprocessors.register(PandocAttrTreeprocessor(md), 'pandoc_attr', 8)
|
| 101 |
+
md.registerExtension(self)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def makeExtension(**kwargs):
|
| 105 |
+
return PandocAttrExtension(**kwargs)
|
text2qti/qti.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import io
|
| 12 |
+
import pathlib
|
| 13 |
+
from typing import Union, BinaryIO
|
| 14 |
+
import zipfile
|
| 15 |
+
from .quiz import Quiz
|
| 16 |
+
from .xml_imsmanifest import imsmanifest
|
| 17 |
+
from .xml_assessment_meta import assessment_meta
|
| 18 |
+
from .xml_assessment import assessment
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class QTI(object):
|
| 22 |
+
'''
|
| 23 |
+
Create QTI from a Quiz object.
|
| 24 |
+
'''
|
| 25 |
+
def __init__(self, quiz: Quiz):
|
| 26 |
+
self.quiz = quiz
|
| 27 |
+
id_base = 'text2qti'
|
| 28 |
+
self.manifest_identifier = f'{id_base}_manifest_{quiz.id}'
|
| 29 |
+
self.assessment_identifier = f'{id_base}_assessment_{quiz.id}'
|
| 30 |
+
self.dependency_identifier = f'{id_base}_dependency_{quiz.id}'
|
| 31 |
+
self.assignment_identifier = f'{id_base}_assignment_{quiz.id}'
|
| 32 |
+
self.assignment_group_identifier = f'{id_base}_assignment-group_{quiz.id}'
|
| 33 |
+
|
| 34 |
+
self.imsmanifest_xml = imsmanifest(manifest_identifier=self.manifest_identifier,
|
| 35 |
+
assessment_identifier=self.assessment_identifier,
|
| 36 |
+
dependency_identifier=self.dependency_identifier,
|
| 37 |
+
images=self.quiz.images)
|
| 38 |
+
self.assessment_meta = assessment_meta(assessment_identifier=self.assessment_identifier,
|
| 39 |
+
assignment_identifier=self.assignment_identifier,
|
| 40 |
+
assignment_group_identifier=self.assignment_group_identifier,
|
| 41 |
+
title_xml=quiz.title_xml,
|
| 42 |
+
description_html_xml=quiz.description_html_xml,
|
| 43 |
+
points_possible=quiz.points_possible,
|
| 44 |
+
shuffle_answers=quiz.shuffle_answers_xml,
|
| 45 |
+
show_correct_answers=quiz.show_correct_answers_xml,
|
| 46 |
+
one_question_at_a_time=quiz.one_question_at_a_time_xml,
|
| 47 |
+
cant_go_back=quiz.cant_go_back_xml)
|
| 48 |
+
self.assessment = assessment(quiz=quiz,
|
| 49 |
+
assessment_identifier=self.assessment_identifier,
|
| 50 |
+
title_xml=quiz.title_xml)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def write(self, bytes_stream: BinaryIO):
|
| 54 |
+
with zipfile.ZipFile(bytes_stream, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
| 55 |
+
zf.writestr('imsmanifest.xml', self.imsmanifest_xml)
|
| 56 |
+
zf.writestr(zipfile.ZipInfo('non_cc_assessments/'), b'')
|
| 57 |
+
zf.writestr(f'{self.assessment_identifier}/assessment_meta.xml', self.assessment_meta)
|
| 58 |
+
zf.writestr(f'{self.assessment_identifier}/{self.assessment_identifier}.xml', self.assessment)
|
| 59 |
+
for image in self.quiz.images.values():
|
| 60 |
+
zf.writestr(image.qti_zip_path, image.data)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def zip_bytes(self) -> bytes:
|
| 64 |
+
stream = io.BytesIO()
|
| 65 |
+
self.write(stream)
|
| 66 |
+
return stream.getvalue()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def save(self, qti_path: Union[str, pathlib.Path]):
|
| 70 |
+
if isinstance(qti_path, str):
|
| 71 |
+
qti_path = pathlib.Path(qti_path)
|
| 72 |
+
elif not isinstance(qti_path, pathlib.Path):
|
| 73 |
+
raise TypeError
|
| 74 |
+
qti_path.write_bytes(self.zip_bytes())
|
text2qti/quiz.py
ADDED
|
@@ -0,0 +1,1186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020-2021, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
'''
|
| 12 |
+
Parse text into a Quiz object that contains a list of Question objects, each
|
| 13 |
+
of which contains a list of Choice objects.
|
| 14 |
+
'''
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
import hashlib
|
| 18 |
+
import io
|
| 19 |
+
import itertools
|
| 20 |
+
import locale
|
| 21 |
+
import pathlib
|
| 22 |
+
import platform
|
| 23 |
+
import re
|
| 24 |
+
import shutil
|
| 25 |
+
import subprocess
|
| 26 |
+
import tempfile
|
| 27 |
+
from typing import Dict, List, Optional, Set, Union
|
| 28 |
+
from .config import Config
|
| 29 |
+
from .err import Text2qtiError
|
| 30 |
+
from .markdown import Image, Markdown
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# regex patterns for parsing quiz content
|
| 36 |
+
start_patterns = {
|
| 37 |
+
'question': r'\d+\.',
|
| 38 |
+
'mctf_correct_choice': r'\*[a-zA-Z]\)',
|
| 39 |
+
'mctf_incorrect_choice': r'[a-zA-Z]\)',
|
| 40 |
+
'multans_correct_choice': r'\[\*\]',
|
| 41 |
+
'multans_incorrect_choice': r'\[ ?\]',
|
| 42 |
+
'shortans_correct_choice': r'\*',
|
| 43 |
+
'feedback': r'\.\.\.',
|
| 44 |
+
'correct_feedback': r'\+',
|
| 45 |
+
'incorrect_feedback': r'\-',
|
| 46 |
+
'solution': r'!',
|
| 47 |
+
'essay': r'___+',
|
| 48 |
+
'upload': r'\^\^\^+',
|
| 49 |
+
'numerical': r'=',
|
| 50 |
+
'question_title': r'[Tt]itle:',
|
| 51 |
+
'question_points': r'[Pp]oints:',
|
| 52 |
+
'text_title': r'[Tt]ext [Tt]itle:',
|
| 53 |
+
'text': r'[Tt]ext:',
|
| 54 |
+
'quiz_title': r'[Qq]uiz [Tt]itle:',
|
| 55 |
+
'quiz_description': r'[Qq]uiz description:',
|
| 56 |
+
'start_group': r'GROUP',
|
| 57 |
+
'end_group': r'END_GROUP',
|
| 58 |
+
'group_pick': r'[Pp]ick:',
|
| 59 |
+
'group_solutions_pick': r'[Ss]olutions pick:',
|
| 60 |
+
'group_points_per_question': r'[Pp]oints per question:',
|
| 61 |
+
'start_code': r'```+\s*\S.*',
|
| 62 |
+
'end_code': r'```+',
|
| 63 |
+
'quiz_shuffle_answers': r'[Ss]huffle answers:',
|
| 64 |
+
'quiz_show_correct_answers': r'[Ss]how correct answers:',
|
| 65 |
+
'quiz_one_question_at_a_time': r'[Oo]ne question at a time:',
|
| 66 |
+
'quiz_cant_go_back': r'''[Cc]an't go back:''',
|
| 67 |
+
'quiz_feedback_is_solution': r'[Ff]eedback is solution:',
|
| 68 |
+
'quiz_solutions_sample_groups': r'[Ss]olutions sample groups:',
|
| 69 |
+
'quiz_solutions_randomize_groups': r'[Ss]olutions randomize groups:',
|
| 70 |
+
}
|
| 71 |
+
# comments are currently handled separately from content
|
| 72 |
+
comment_patterns = {
|
| 73 |
+
'start_multiline_comment': r'COMMENT',
|
| 74 |
+
'end_multiline_comment': r'END_COMMENT',
|
| 75 |
+
'line_comment': r'%',
|
| 76 |
+
}
|
| 77 |
+
# whether regex needs to check after pattern for content on the same line
|
| 78 |
+
no_content = set(['essay', 'upload', 'start_group', 'end_group', 'start_code', 'end_code'])
|
| 79 |
+
# whether parser needs to check for multi-line content
|
| 80 |
+
single_line = set(['question_points', 'group_pick', 'group_solutions_pick', 'group_points_per_question',
|
| 81 |
+
'numerical', 'shortans_correct_choice',
|
| 82 |
+
'quiz_shuffle_answers', 'quiz_show_correct_answers',
|
| 83 |
+
'quiz_one_question_at_a_time', 'quiz_cant_go_back',
|
| 84 |
+
'quiz_feedback_is_solution', 'quiz_solutions_sample_groups', 'quiz_solutions_randomize_groups'])
|
| 85 |
+
multi_line = set([x for x in start_patterns
|
| 86 |
+
if x not in no_content and x not in single_line])
|
| 87 |
+
# whether parser needs to check for multi-paragraph content
|
| 88 |
+
multi_para = set([x for x in multi_line if 'title' not in x])
|
| 89 |
+
start_re = re.compile('|'.join(r'(?P<{0}>{1}[ \t]+(?=\S))'.format(name, pattern)
|
| 90 |
+
if name not in no_content else
|
| 91 |
+
r'(?P<{0}>{1}\s*)$'.format(name, pattern)
|
| 92 |
+
for name, pattern in start_patterns.items()))
|
| 93 |
+
start_missing_content_re = re.compile('|'.join(r'(?P<{0}>{1}[ \t]*$)'.format(name, pattern)
|
| 94 |
+
for name, pattern in start_patterns.items()
|
| 95 |
+
if name not in no_content))
|
| 96 |
+
start_missing_whitespace_re = re.compile('|'.join(r'(?P<{0}>{1}(?=\S))'.format(name, pattern)
|
| 97 |
+
for name, pattern in start_patterns.items()
|
| 98 |
+
if name not in no_content))
|
| 99 |
+
start_code_supported_info_re = re.compile(r'\{\s*'
|
| 100 |
+
r'\.(?P<lang>[a-zA-Z](?:[a-zA-Z0-9]+|[\._\-]+[a-zA-Z0-9]+)*)'
|
| 101 |
+
r'\s+'
|
| 102 |
+
r'\.run'
|
| 103 |
+
r'(?:\s+executable=(?P<executable>[~\w/\.\-]+|"[^\\\"\']+"))?'
|
| 104 |
+
r'\s*\}$')
|
| 105 |
+
int_re = re.compile('(?:0|[+-]?[1-9](?:[0-9]+|_[0-9]+)*)$')
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class TextRegion(object):
|
| 111 |
+
'''
|
| 112 |
+
A text region between questions.
|
| 113 |
+
'''
|
| 114 |
+
def __init__(self, *, index: int, md: Markdown):
|
| 115 |
+
self.title_raw: Optional[str] = None
|
| 116 |
+
self.title_xml = ''
|
| 117 |
+
self.text_raw: Optional[str] = None
|
| 118 |
+
self.text_html_xml = ''
|
| 119 |
+
self.md = md
|
| 120 |
+
self._index = index
|
| 121 |
+
|
| 122 |
+
def _set_id(self):
|
| 123 |
+
h = hashlib.blake2b()
|
| 124 |
+
h.update(f'{self._index}'.encode('utf8'))
|
| 125 |
+
h.update(h.digest())
|
| 126 |
+
h.update(self.title_xml.encode('utf8'))
|
| 127 |
+
h.update(h.digest())
|
| 128 |
+
h.update(self.text_html_xml.encode('utf8'))
|
| 129 |
+
self.id = h.hexdigest()[:64]
|
| 130 |
+
|
| 131 |
+
def set_title(self, text: str):
|
| 132 |
+
if self.title_raw is not None:
|
| 133 |
+
raise Text2qtiError('Text title has already been set')
|
| 134 |
+
if self.text_raw is not None:
|
| 135 |
+
raise Text2qtiError('Must set text title before text itself')
|
| 136 |
+
self.title_raw = text
|
| 137 |
+
self.title_xml = self.md.xml_escape(text)
|
| 138 |
+
self._set_id()
|
| 139 |
+
|
| 140 |
+
def set_text(self, text: str):
|
| 141 |
+
if self.text_raw is not None:
|
| 142 |
+
raise Text2qtiError('Text has already been set')
|
| 143 |
+
self.text_raw = text
|
| 144 |
+
self.text_html_xml = self.md.md_to_html_xml(text)
|
| 145 |
+
self._set_id()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class Choice(object):
|
| 151 |
+
'''
|
| 152 |
+
A choice for a question plus optional feedback.
|
| 153 |
+
|
| 154 |
+
The id is based on a hash of both the question and the choice itself.
|
| 155 |
+
The presence of feedback does not affect the id.
|
| 156 |
+
'''
|
| 157 |
+
def __init__(self, text: str, *,
|
| 158 |
+
correct: bool, shortans: bool=False,
|
| 159 |
+
question_hash_digest: bytes, md: Markdown):
|
| 160 |
+
self.choice_raw = text
|
| 161 |
+
if shortans:
|
| 162 |
+
self.choice_xml = md.xml_escape(text)
|
| 163 |
+
else:
|
| 164 |
+
self.choice_html_xml = md.md_to_html_xml(text)
|
| 165 |
+
self.correct = correct
|
| 166 |
+
self.shortans = shortans
|
| 167 |
+
self.feedback_raw: Optional[str] = None
|
| 168 |
+
self.feedback_html_xml: Optional[str] = None
|
| 169 |
+
# ID is based on hash of choice XML as well as question XML. This
|
| 170 |
+
# gives different IDs for identical choices in different questions.
|
| 171 |
+
if shortans:
|
| 172 |
+
self.id = hashlib.blake2b(self.choice_xml.encode('utf8'), key=question_hash_digest).hexdigest()[:64]
|
| 173 |
+
else:
|
| 174 |
+
self.id = hashlib.blake2b(self.choice_html_xml.encode('utf8'), key=question_hash_digest).hexdigest()[:64]
|
| 175 |
+
self.md = md
|
| 176 |
+
|
| 177 |
+
def append_feedback(self, text: str):
|
| 178 |
+
if self.shortans:
|
| 179 |
+
raise Text2qtiError('Feedback cannot be specified for individual short answer responses, only for the question')
|
| 180 |
+
if self.feedback_raw is not None:
|
| 181 |
+
raise Text2qtiError('Feedback can only be specified once')
|
| 182 |
+
self.feedback_raw = text
|
| 183 |
+
self.feedback_html_xml = self.md.md_to_html_xml(text)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class Question(object):
|
| 187 |
+
'''
|
| 188 |
+
A question, along with a list of possible choices and optional feedback of
|
| 189 |
+
various types.
|
| 190 |
+
'''
|
| 191 |
+
def __init__(self, text: str, *, quiz: 'Quiz', title: Optional[str], points: Optional[str], md: Markdown):
|
| 192 |
+
# Question type is set once it is known. For true/false or multiple
|
| 193 |
+
# choice, this is done during .finalize(), once all choices are
|
| 194 |
+
# available. For essay, this is done as soon as essay response is
|
| 195 |
+
# specified.
|
| 196 |
+
self.type: Optional[str] = None
|
| 197 |
+
self.quiz: 'Quiz' = quiz
|
| 198 |
+
if title is None:
|
| 199 |
+
self.title_raw: Optional[str] = None
|
| 200 |
+
self.title_xml = 'Question'
|
| 201 |
+
else:
|
| 202 |
+
self.title_raw: Optional[str] = title
|
| 203 |
+
self.title_xml = md.xml_escape(title)
|
| 204 |
+
self.question_raw = text
|
| 205 |
+
self.question_html_xml = md.md_to_html_xml(text)
|
| 206 |
+
self.choices: List[Choice] = []
|
| 207 |
+
# The set for detecting duplicate choices uses the XML version of the
|
| 208 |
+
# choices, to avoid the issue of multiple Markdown representations of
|
| 209 |
+
# the same XML.
|
| 210 |
+
self._choice_set: Set[str] = set()
|
| 211 |
+
self.numerical_raw: Optional[str] = None
|
| 212 |
+
self.numerical_min: Optional[Union[int, float]] = None
|
| 213 |
+
self.numerical_min_html_xml: Optional[str] = None
|
| 214 |
+
self.numerical_exact: Optional[Union[int, float]] = None
|
| 215 |
+
self.numerical_exact_html_xml: Optional[str] = None
|
| 216 |
+
self.numerical_max: Optional[Union[int, float]] = None
|
| 217 |
+
self.numerical_max_html_xml: Optional[str] = None
|
| 218 |
+
self.correct_choices = 0
|
| 219 |
+
if points is None:
|
| 220 |
+
self.points_possible_raw: Optional[str] = None
|
| 221 |
+
self.points_possible: Union[int, float] = 1
|
| 222 |
+
else:
|
| 223 |
+
self.points_possible_raw: Optional[str] = points
|
| 224 |
+
try:
|
| 225 |
+
points_num = float(points)
|
| 226 |
+
except ValueError:
|
| 227 |
+
raise Text2qtiError(f'Invalid points value "{points}"; need positive integer or half-integer')
|
| 228 |
+
if points_num <= 0:
|
| 229 |
+
raise Text2qtiError(f'Invalid points value "{points}"; need positive integer or half-integer')
|
| 230 |
+
if points_num.is_integer():
|
| 231 |
+
points_num = int(points)
|
| 232 |
+
elif abs(points_num-round(points_num)) != 0.5:
|
| 233 |
+
raise Text2qtiError(f'Invalid points value "{points}"; need positive integer or half-integer')
|
| 234 |
+
self.points_possible: Union[int, float] = points_num
|
| 235 |
+
self.feedback_raw: Optional[str] = None
|
| 236 |
+
self.feedback_html_xml: Optional[str] = None
|
| 237 |
+
self.correct_feedback_raw: Optional[str] = None
|
| 238 |
+
self.correct_feedback_html_xml: Optional[str] = None
|
| 239 |
+
self.incorrect_feedback_raw: Optional[str] = None
|
| 240 |
+
self.incorrect_feedback_html_xml: Optional[str] = None
|
| 241 |
+
self.solution: Optional[str] = None
|
| 242 |
+
h = hashlib.blake2b(self.question_html_xml.encode('utf8'))
|
| 243 |
+
self.hash_digest = h.digest()
|
| 244 |
+
self.id = h.hexdigest()[:64]
|
| 245 |
+
self.md = md
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def append_feedback(self, text: str):
|
| 249 |
+
if self.type is not None and not self.choices:
|
| 250 |
+
raise Text2qtiError('Question feedback must immediately follow the question')
|
| 251 |
+
if not self.choices:
|
| 252 |
+
if self.feedback_raw is not None:
|
| 253 |
+
raise Text2qtiError('Feedback can only be specified once')
|
| 254 |
+
self.feedback_raw = text
|
| 255 |
+
self.feedback_html_xml = self.md.md_to_html_xml(text)
|
| 256 |
+
if self.quiz.feedback_is_solution:
|
| 257 |
+
self.solution = text
|
| 258 |
+
else:
|
| 259 |
+
self.choices[-1].append_feedback(text)
|
| 260 |
+
|
| 261 |
+
def append_correct_feedback(self, text: str):
|
| 262 |
+
if self.type is not None:
|
| 263 |
+
raise Text2qtiError('Correct feedback can only be specified for questions')
|
| 264 |
+
if self.correct_feedback_raw is not None:
|
| 265 |
+
raise Text2qtiError('Feedback can only be specified once')
|
| 266 |
+
self.correct_feedback_raw = text
|
| 267 |
+
self.correct_feedback_html_xml = self.md.md_to_html_xml(text)
|
| 268 |
+
|
| 269 |
+
def append_incorrect_feedback(self, text: str):
|
| 270 |
+
if self.type is not None:
|
| 271 |
+
raise Text2qtiError('Incorrect feedback can only be specified for questions')
|
| 272 |
+
if self.incorrect_feedback_raw is not None:
|
| 273 |
+
raise Text2qtiError('Feedback can only be specified once')
|
| 274 |
+
self.incorrect_feedback_raw = text
|
| 275 |
+
self.incorrect_feedback_html_xml = self.md.md_to_html_xml(text)
|
| 276 |
+
|
| 277 |
+
def append_solution(self, text: str):
|
| 278 |
+
if self.type is not None:
|
| 279 |
+
raise Text2qtiError('Solutions can only be specified for questions')
|
| 280 |
+
if self.solution is not None:
|
| 281 |
+
raise Text2qtiError('Solutions can only be specified once')
|
| 282 |
+
if self.quiz.feedback_is_solution:
|
| 283 |
+
raise Text2qtiError('Solutions syntax with "!" is disabled for quizzes with setting "feedback is solution: true"')
|
| 284 |
+
self.solution = text
|
| 285 |
+
|
| 286 |
+
def append_mctf_correct_choice(self, text: str):
|
| 287 |
+
if self.type is None:
|
| 288 |
+
self.type = 'multiple_choice_question'
|
| 289 |
+
elif self.type != 'multiple_choice_question':
|
| 290 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support multiple choice')
|
| 291 |
+
choice = Choice(text, correct=True, question_hash_digest=self.hash_digest, md=self.md)
|
| 292 |
+
if choice.choice_html_xml in self._choice_set:
|
| 293 |
+
raise Text2qtiError('Duplicate choice for question')
|
| 294 |
+
self._choice_set.add(choice.choice_html_xml)
|
| 295 |
+
self.choices.append(choice)
|
| 296 |
+
self.correct_choices += 1
|
| 297 |
+
|
| 298 |
+
def append_mctf_incorrect_choice(self, text: str):
|
| 299 |
+
if self.type is None:
|
| 300 |
+
self.type = 'multiple_choice_question'
|
| 301 |
+
elif self.type != 'multiple_choice_question':
|
| 302 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support multiple choice')
|
| 303 |
+
choice = Choice(text, correct=False, question_hash_digest=self.hash_digest, md=self.md)
|
| 304 |
+
if choice.choice_html_xml in self._choice_set:
|
| 305 |
+
raise Text2qtiError('Duplicate choice for question')
|
| 306 |
+
self._choice_set.add(choice.choice_html_xml)
|
| 307 |
+
self.choices.append(choice)
|
| 308 |
+
|
| 309 |
+
def append_shortans_correct_choice(self, text: str):
|
| 310 |
+
if self.type is None:
|
| 311 |
+
self.type = 'short_answer_question'
|
| 312 |
+
elif self.type != 'short_answer_question':
|
| 313 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support short answer')
|
| 314 |
+
choice = Choice(text, correct=True, shortans=True, question_hash_digest=self.hash_digest, md=self.md)
|
| 315 |
+
if choice.choice_xml in self._choice_set:
|
| 316 |
+
raise Text2qtiError('Duplicate choice for question')
|
| 317 |
+
self._choice_set.add(choice.choice_xml)
|
| 318 |
+
self.choices.append(choice)
|
| 319 |
+
self.correct_choices += 1
|
| 320 |
+
|
| 321 |
+
def append_multans_correct_choice(self, text: str):
|
| 322 |
+
if self.type is None:
|
| 323 |
+
self.type = 'multiple_answers_question'
|
| 324 |
+
elif self.type != 'multiple_answers_question':
|
| 325 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support multiple answers')
|
| 326 |
+
choice = Choice(text, correct=True, question_hash_digest=self.hash_digest, md=self.md)
|
| 327 |
+
if choice.choice_html_xml in self._choice_set:
|
| 328 |
+
raise Text2qtiError('Duplicate choice for question')
|
| 329 |
+
self._choice_set.add(choice.choice_html_xml)
|
| 330 |
+
self.choices.append(choice)
|
| 331 |
+
self.correct_choices += 1
|
| 332 |
+
|
| 333 |
+
def append_multans_incorrect_choice(self, text: str):
|
| 334 |
+
if self.type is None:
|
| 335 |
+
self.type = 'multiple_answers_question'
|
| 336 |
+
elif self.type != 'multiple_answers_question':
|
| 337 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support multiple answers')
|
| 338 |
+
choice = Choice(text, correct=False, question_hash_digest=self.hash_digest, md=self.md)
|
| 339 |
+
if choice.choice_html_xml in self._choice_set:
|
| 340 |
+
raise Text2qtiError('Duplicate choice for question')
|
| 341 |
+
self._choice_set.add(choice.choice_html_xml)
|
| 342 |
+
self.choices.append(choice)
|
| 343 |
+
|
| 344 |
+
def append_essay(self, text: str):
|
| 345 |
+
if text:
|
| 346 |
+
# The essay response syntax provides no text to process; the
|
| 347 |
+
# `text` argument just gives all append functions the same form.
|
| 348 |
+
raise ValueError
|
| 349 |
+
if self.type is not None:
|
| 350 |
+
if self.type == 'essay_question':
|
| 351 |
+
raise Text2qtiError(f'Cannot specify essay response multiple times')
|
| 352 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support essay response')
|
| 353 |
+
self.type = 'essay_question'
|
| 354 |
+
if any(x is not None for x in (self.correct_feedback_raw, self.incorrect_feedback_raw)):
|
| 355 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support correct/incorrect feedback')
|
| 356 |
+
|
| 357 |
+
def append_upload(self, text: str):
|
| 358 |
+
if text:
|
| 359 |
+
# The upload response syntax provides no text to process; the
|
| 360 |
+
# `text` argument just gives all append functions the same form.
|
| 361 |
+
raise ValueError
|
| 362 |
+
if self.type is not None:
|
| 363 |
+
if self.type == 'file_upload_question':
|
| 364 |
+
raise Text2qtiError(f'Cannot specify upload response multiple times')
|
| 365 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support upload response')
|
| 366 |
+
self.type = 'file_upload_question'
|
| 367 |
+
if any(x is not None for x in (self.correct_feedback_raw, self.incorrect_feedback_raw)):
|
| 368 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support correct/incorrect feedback')
|
| 369 |
+
|
| 370 |
+
def append_numerical(self, text: str):
|
| 371 |
+
if self.type is not None:
|
| 372 |
+
if self.type == 'numerical_question':
|
| 373 |
+
raise Text2qtiError(f'Cannot specify numerical response multiple times')
|
| 374 |
+
raise Text2qtiError(f'Question type "{self.type}" does not support numerical response')
|
| 375 |
+
self.type = 'numerical_question'
|
| 376 |
+
self.numerical_raw = text
|
| 377 |
+
if text.startswith('['):
|
| 378 |
+
if not text.endswith(']') or ',' not in text:
|
| 379 |
+
raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" or "<number> +- <margin>" or "<integer>"')
|
| 380 |
+
min, max = text[1:-1].split(',', 1)
|
| 381 |
+
try:
|
| 382 |
+
min = float(min)
|
| 383 |
+
max = float(max)
|
| 384 |
+
except Exception:
|
| 385 |
+
raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" or "<number> +- <margin>" or "<integer>"')
|
| 386 |
+
if min > max:
|
| 387 |
+
raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" with min < max')
|
| 388 |
+
self.numerical_min = min
|
| 389 |
+
self.numerical_max = max
|
| 390 |
+
if min.is_integer() and max.is_integer():
|
| 391 |
+
self.numerical_min_html_xml = f'{min}'
|
| 392 |
+
self.numerical_max_html_xml = f'{max}'
|
| 393 |
+
else:
|
| 394 |
+
self.numerical_min_html_xml = f'{min:.4f}'
|
| 395 |
+
self.numerical_max_html_xml = f'{max:.4f}'
|
| 396 |
+
elif '+-' in text:
|
| 397 |
+
num, margin = text.split('+-', 1)
|
| 398 |
+
if margin.endswith('%'):
|
| 399 |
+
margin_is_percentage = True
|
| 400 |
+
margin = margin[:-1]
|
| 401 |
+
else:
|
| 402 |
+
margin_is_percentage = False
|
| 403 |
+
try:
|
| 404 |
+
num = float(num)
|
| 405 |
+
margin = float(margin)
|
| 406 |
+
except Exception:
|
| 407 |
+
raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" or "<number> +- <margin>" or "<integer>"')
|
| 408 |
+
if margin < 0:
|
| 409 |
+
raise Text2qtiError('Invalid numerical response; need "<number> +- <margin>" with margin > 0')
|
| 410 |
+
if margin_is_percentage:
|
| 411 |
+
min = num - abs(num)*(margin/100)
|
| 412 |
+
max = num + abs(num)*(margin/100)
|
| 413 |
+
else:
|
| 414 |
+
min = num - margin
|
| 415 |
+
max = num + margin
|
| 416 |
+
self.numerical_min = min
|
| 417 |
+
self.numerical_exact = num
|
| 418 |
+
self.numerical_max = max
|
| 419 |
+
if min.is_integer() and num.is_integer() and max.is_integer():
|
| 420 |
+
self.numerical_min_html_xml = f'{min}'
|
| 421 |
+
self.numerical_exact_html_xml = f'{num}'
|
| 422 |
+
self.numerical_max_html_xml = f'{max}'
|
| 423 |
+
else:
|
| 424 |
+
self.numerical_min_html_xml = f'{min:.4f}'
|
| 425 |
+
self.numerical_exact_html_xml = f'{num:.4f}'
|
| 426 |
+
self.numerical_max_html_xml = f'{max:.4f}'
|
| 427 |
+
elif int_re.match(text):
|
| 428 |
+
num = int(text)
|
| 429 |
+
min = max = num
|
| 430 |
+
self.numerical_min = min
|
| 431 |
+
self.numerical_exact = num
|
| 432 |
+
self.numerical_max = max
|
| 433 |
+
self.numerical_min_html_xml = f'{min}'
|
| 434 |
+
self.numerical_exact_html_xml = f'{num}'
|
| 435 |
+
self.numerical_max_html_xml = f'{max}'
|
| 436 |
+
else:
|
| 437 |
+
raise Text2qtiError('Invalid numerical response; need "[<min>, <max>]" or "<number> +- <margin>" or "<integer>"')
|
| 438 |
+
if abs(min) < 1e-4 or abs(max) < 1e-4:
|
| 439 |
+
raise Text2qtiError('Invalid numerical response; all acceptable values must have a magnitude >= 0.0001')
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def finalize(self):
|
| 443 |
+
if self.type is None:
|
| 444 |
+
raise Text2qtiError('Question must specify a response type')
|
| 445 |
+
elif self.type == 'multiple_choice_question':
|
| 446 |
+
if len(self.choices) == 2 and all(c.choice_raw in ('true', 'True', 'false', 'False') for c in self.choices):
|
| 447 |
+
self.type = 'true_false_question'
|
| 448 |
+
if not self.choices:
|
| 449 |
+
raise Text2qtiError('Question must provide choices')
|
| 450 |
+
if len(self.choices) < 2:
|
| 451 |
+
raise Text2qtiError('Question must provide more than one choice')
|
| 452 |
+
if self.correct_choices < 1:
|
| 453 |
+
raise Text2qtiError('Question must specify a correct choice')
|
| 454 |
+
if self.correct_choices > 1:
|
| 455 |
+
raise Text2qtiError('Question must specify only one correct choice')
|
| 456 |
+
elif self.type == 'short_answer_question':
|
| 457 |
+
if not self.choices:
|
| 458 |
+
raise Text2qtiError('Question must provide at least one answer')
|
| 459 |
+
elif self.type == 'multiple_answers_question':
|
| 460 |
+
if len(self.choices) < 2:
|
| 461 |
+
raise Text2qtiError('Question must provide more than one choice')
|
| 462 |
+
if self.correct_choices < 1:
|
| 463 |
+
raise Text2qtiError('Question must specify a correct choice')
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
class Group(object):
|
| 469 |
+
'''
|
| 470 |
+
A group of questions. A random subset of the questions in a group is
|
| 471 |
+
actually displayed.
|
| 472 |
+
'''
|
| 473 |
+
def __init__(self):
|
| 474 |
+
self.pick = 1
|
| 475 |
+
self._pick_is_set = False
|
| 476 |
+
self.solutions_pick: Optional[int] = None
|
| 477 |
+
self.points_per_question = 1
|
| 478 |
+
self._points_per_question_is_set = False
|
| 479 |
+
self.questions: List[Question] = []
|
| 480 |
+
self._question_points_possible: Optional[Union[int, float]] = None
|
| 481 |
+
self.title_raw: Optional[str] = None
|
| 482 |
+
self.title_xml = 'Group'
|
| 483 |
+
|
| 484 |
+
def append_group_pick(self, text: str):
|
| 485 |
+
if self.questions:
|
| 486 |
+
raise Text2qtiError('Question group options must be set at the very start of the group')
|
| 487 |
+
if self._pick_is_set:
|
| 488 |
+
Text2qtiError('"Pick" has already been set for this question group')
|
| 489 |
+
try:
|
| 490 |
+
self.pick = int(text)
|
| 491 |
+
except Exception as e:
|
| 492 |
+
raise Text2qtiError(f'"pick" value is invalid (must be positive number):\n{e}')
|
| 493 |
+
if self.pick <= 0:
|
| 494 |
+
raise Text2qtiError('"pick" value is invalid (must be positive number)')
|
| 495 |
+
if self.solutions_pick is not None and self.pick > self.solutions_pick:
|
| 496 |
+
raise Text2qtiError('"pick" value must be less than or equal to "solutions pick" value')
|
| 497 |
+
self._pick_is_set = True
|
| 498 |
+
|
| 499 |
+
def append_group_solutions_pick(self, text: str):
|
| 500 |
+
if self.questions:
|
| 501 |
+
raise Text2qtiError('Question group options must be set at the very start of the group')
|
| 502 |
+
if self.solutions_pick is not None:
|
| 503 |
+
Text2qtiError('"solutions pick" has already been set for this question group')
|
| 504 |
+
try:
|
| 505 |
+
self.solutions_pick = int(text)
|
| 506 |
+
except Exception as e:
|
| 507 |
+
raise Text2qtiError(f'"solutions pick" value is invalid (must be positive number):\n{e}')
|
| 508 |
+
if self.solutions_pick <= 0:
|
| 509 |
+
raise Text2qtiError('"solutions pick" value is invalid (must be positive number)')
|
| 510 |
+
if self.solutions_pick < self.pick:
|
| 511 |
+
raise Text2qtiError('"solutions pick" value must be greater than or equal to "pick" value')
|
| 512 |
+
|
| 513 |
+
def append_group_points_per_question(self, text: str):
|
| 514 |
+
if self.questions:
|
| 515 |
+
raise Text2qtiError('Question group options must be set at the very start of the group')
|
| 516 |
+
if self._points_per_question_is_set:
|
| 517 |
+
Text2qtiError('"Points per question" has already been set for this question group')
|
| 518 |
+
try:
|
| 519 |
+
self.points_per_question = int(text)
|
| 520 |
+
except Exception as e:
|
| 521 |
+
raise Text2qtiError(f'"Points per question" value is invalid (must be positive number):\n{e}')
|
| 522 |
+
if self.points_per_question <= 0:
|
| 523 |
+
raise Text2qtiError(f'"Points per question" value is invalid (must be positive number):')
|
| 524 |
+
self._points_per_question_is_set = True
|
| 525 |
+
|
| 526 |
+
def append_question(self, question: Question):
|
| 527 |
+
if self._question_points_possible is None:
|
| 528 |
+
self._question_points_possible = question.points_possible
|
| 529 |
+
elif question.points_possible != self._question_points_possible:
|
| 530 |
+
raise Text2qtiError('Question groups must only contain questions with the same point value')
|
| 531 |
+
self.questions.append(question)
|
| 532 |
+
|
| 533 |
+
def finalize(self):
|
| 534 |
+
if len(self.questions) < self.pick:
|
| 535 |
+
raise Text2qtiError(f'Question group only contains {len(self.questions)} questions, needs at least {self.pick+1}')
|
| 536 |
+
if self.solutions_pick is not None and len(self.questions) < self.solutions_pick:
|
| 537 |
+
raise Text2qtiError(f'Question group only contains {len(self.questions)} questions, needs at least {self.solutions_pick}')
|
| 538 |
+
h = hashlib.blake2b()
|
| 539 |
+
for digest in sorted(q.hash_digest for q in self.questions):
|
| 540 |
+
h.update(digest)
|
| 541 |
+
self.hash_digest = h.digest()
|
| 542 |
+
self.id = h.hexdigest()[:64]
|
| 543 |
+
|
| 544 |
+
class GroupStart(object):
|
| 545 |
+
'''
|
| 546 |
+
Start delim for a group of questions.
|
| 547 |
+
'''
|
| 548 |
+
def __init__(self, group: Group):
|
| 549 |
+
self.group = group
|
| 550 |
+
|
| 551 |
+
class GroupEnd(object):
|
| 552 |
+
'''
|
| 553 |
+
End delim for a group of questions.
|
| 554 |
+
'''
|
| 555 |
+
def __init__(self, group: Group):
|
| 556 |
+
self.group = group
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
class Quiz(object):
|
| 562 |
+
'''
|
| 563 |
+
A quiz or assessment. Contains a list of questions along with possible
|
| 564 |
+
choices and feedback.
|
| 565 |
+
'''
|
| 566 |
+
def __init__(self, string: str, *, config: Config,
|
| 567 |
+
source_name: Optional[str]=None,
|
| 568 |
+
resource_path: Optional[Union[str, pathlib.Path]]=None):
|
| 569 |
+
self.string = string
|
| 570 |
+
self.config = config
|
| 571 |
+
self.source_name = '<string>' if source_name is None else f'"{source_name}"'
|
| 572 |
+
if resource_path is not None:
|
| 573 |
+
if isinstance(resource_path, str):
|
| 574 |
+
resource_path = pathlib.Path(resource_path)
|
| 575 |
+
else:
|
| 576 |
+
raise TypeError
|
| 577 |
+
if not resource_path.is_dir():
|
| 578 |
+
raise Text2qtiError(f'Resource path "{resource_path.as_posix()}" does not exist')
|
| 579 |
+
self.resource_path = resource_path
|
| 580 |
+
self.title_raw = None
|
| 581 |
+
self.title_xml = 'Quiz'
|
| 582 |
+
self.description_raw = None
|
| 583 |
+
self.description_html_xml = ''
|
| 584 |
+
self.shuffle_answers_raw = None
|
| 585 |
+
self.shuffle_answers_xml = 'false'
|
| 586 |
+
self.show_correct_answers_raw = None
|
| 587 |
+
self.show_correct_answers_xml = 'true'
|
| 588 |
+
self.one_question_at_a_time_raw = None
|
| 589 |
+
self.one_question_at_a_time_xml = 'false'
|
| 590 |
+
self.cant_go_back_raw = None
|
| 591 |
+
self.cant_go_back_xml = 'false'
|
| 592 |
+
self.feedback_is_solution: Optional[bool] = None
|
| 593 |
+
self.solutions_sample_groups: Optional[bool] = None
|
| 594 |
+
self.solutions_randomize_groups: Optional[bool] = None
|
| 595 |
+
self.questions_and_delims: List[Union[Question, GroupStart, GroupEnd, TextRegion]] = []
|
| 596 |
+
self._current_group: Optional[Group] = None
|
| 597 |
+
# The set for detecting duplicate questions uses the XML version of
|
| 598 |
+
# the question, to avoid the issue of multiple Markdown
|
| 599 |
+
# representations of the same XML.
|
| 600 |
+
self.question_set: Set[str] = set()
|
| 601 |
+
self.md = Markdown(config)
|
| 602 |
+
self.images: Dict[str, Image] = self.md.images
|
| 603 |
+
self._next_question_attr = {}
|
| 604 |
+
|
| 605 |
+
# Determine how to interpret `.python` for executable code blocks.
|
| 606 |
+
# If `python3` exists, use it instead of `python` if `python` does not
|
| 607 |
+
# exist or if `python` is equivalent to `python2`.
|
| 608 |
+
if not shutil.which('python2') or not shutil.which('python3'):
|
| 609 |
+
python_executable = 'python'
|
| 610 |
+
elif not shutil.which('python'):
|
| 611 |
+
python_executable = 'python3'
|
| 612 |
+
elif pathlib.Path(shutil.which('python')).resolve() == pathlib.Path(shutil.which('python2')).resolve():
|
| 613 |
+
python_executable = 'python3'
|
| 614 |
+
else:
|
| 615 |
+
python_executable = 'python'
|
| 616 |
+
|
| 617 |
+
try:
|
| 618 |
+
parse_actions = {}
|
| 619 |
+
for k in start_patterns:
|
| 620 |
+
parse_actions[k] = getattr(self, f'append_{k}')
|
| 621 |
+
parse_actions[None] = self.append_unknown
|
| 622 |
+
start_multiline_comment_pattern = comment_patterns['start_multiline_comment']
|
| 623 |
+
end_multiline_comment_pattern = comment_patterns['end_multiline_comment']
|
| 624 |
+
line_comment_pattern = comment_patterns['line_comment']
|
| 625 |
+
n_line_iter = iter(x for x in enumerate(string.splitlines()))
|
| 626 |
+
n, line = next(n_line_iter, (0, None))
|
| 627 |
+
lookahead = False
|
| 628 |
+
n_code_start = 0
|
| 629 |
+
while line is not None:
|
| 630 |
+
match = start_re.match(line)
|
| 631 |
+
if match:
|
| 632 |
+
action = match.lastgroup
|
| 633 |
+
text = line[match.end():].strip()
|
| 634 |
+
if action == 'start_code':
|
| 635 |
+
info = line.lstrip('`').strip()
|
| 636 |
+
info_match = start_code_supported_info_re.match(info)
|
| 637 |
+
if info_match is None:
|
| 638 |
+
pass
|
| 639 |
+
else:
|
| 640 |
+
executable = info_match.group('executable')
|
| 641 |
+
if executable is not None:
|
| 642 |
+
if executable.startswith('"'):
|
| 643 |
+
executable = executable[1:-1]
|
| 644 |
+
executable = pathlib.Path(executable).expanduser().as_posix()
|
| 645 |
+
else:
|
| 646 |
+
executable = info_match.group('lang')
|
| 647 |
+
if executable == 'python':
|
| 648 |
+
executable = python_executable
|
| 649 |
+
delim = '`'*(len(line) - len(line.lstrip('`')))
|
| 650 |
+
n_code_start = n
|
| 651 |
+
code_lines = []
|
| 652 |
+
n, line = next(n_line_iter, (0, None))
|
| 653 |
+
# No lookahead here; all lines are consumed
|
| 654 |
+
while line is not None and not (line.startswith(delim) and line[len(delim):] == line.lstrip('`')):
|
| 655 |
+
code_lines.append(line)
|
| 656 |
+
n, line = next(n_line_iter, (0, None))
|
| 657 |
+
if line is None:
|
| 658 |
+
raise Text2qtiError(f'In {self.source_name} on line {n}:\nCode closing fence is missing')
|
| 659 |
+
if line.lstrip('`').strip():
|
| 660 |
+
raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nCode closing fence is missing')
|
| 661 |
+
code_lines.append('\n')
|
| 662 |
+
code = '\n'.join(code_lines)
|
| 663 |
+
try:
|
| 664 |
+
stdout = self._run_code(executable, code)
|
| 665 |
+
except Exception as e:
|
| 666 |
+
raise Text2qtiError(f'In {self.source_name} on line {n_code_start+1}:\n{e}')
|
| 667 |
+
code_n_line_iter = ((n_code_start, stdout_line) for stdout_line in stdout.splitlines())
|
| 668 |
+
n_line_iter = itertools.chain(code_n_line_iter, n_line_iter)
|
| 669 |
+
n, line = next(n_line_iter, (0, None))
|
| 670 |
+
continue
|
| 671 |
+
elif action in multi_line:
|
| 672 |
+
if start_patterns[action].endswith(':'):
|
| 673 |
+
indent_expandtabs = None
|
| 674 |
+
else:
|
| 675 |
+
indent_expandtabs = ' '*len(line[:match.end()].expandtabs(4))
|
| 676 |
+
text_lines = [text]
|
| 677 |
+
n, line = next(n_line_iter, (0, None))
|
| 678 |
+
line_expandtabs = line.expandtabs(4) if line is not None else None
|
| 679 |
+
lookahead = True
|
| 680 |
+
while (line is not None and
|
| 681 |
+
(not line or line.isspace() or
|
| 682 |
+
indent_expandtabs is None or line_expandtabs.startswith(indent_expandtabs))):
|
| 683 |
+
if not line or line.isspace():
|
| 684 |
+
if action in multi_para:
|
| 685 |
+
text_lines.append('')
|
| 686 |
+
else:
|
| 687 |
+
break
|
| 688 |
+
else:
|
| 689 |
+
if indent_expandtabs is None:
|
| 690 |
+
if not line.startswith(' '):
|
| 691 |
+
break
|
| 692 |
+
indent_expandtabs = ' '*(len(line_expandtabs)-len(line_expandtabs.lstrip(' ')))
|
| 693 |
+
if len(indent_expandtabs) < 2:
|
| 694 |
+
raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nIndentation must be at least 2 spaces or 1 tab here')
|
| 695 |
+
# The `rstrip()` prevents trailing double
|
| 696 |
+
# spaces from becoming `<br />`.
|
| 697 |
+
text_lines.append(line_expandtabs[len(indent_expandtabs):].rstrip())
|
| 698 |
+
n, line = next(n_line_iter, (0, None))
|
| 699 |
+
line_expandtabs = line.expandtabs(4) if line is not None else None
|
| 700 |
+
if text_lines and not text_lines[-1]:
|
| 701 |
+
while text_lines and not text_lines[-1]:
|
| 702 |
+
text_lines.pop()
|
| 703 |
+
text = '\n'.join(text_lines)
|
| 704 |
+
elif line.startswith(line_comment_pattern):
|
| 705 |
+
n, line = next(n_line_iter, (0, None))
|
| 706 |
+
continue
|
| 707 |
+
elif line.startswith(start_multiline_comment_pattern):
|
| 708 |
+
if line.strip() != start_multiline_comment_pattern:
|
| 709 |
+
raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nUnexpected content after "{start_multiline_comment_pattern}"')
|
| 710 |
+
n, line = next(n_line_iter, (0, None))
|
| 711 |
+
while line is not None and not line.startswith(end_multiline_comment_pattern):
|
| 712 |
+
n, line = next(n_line_iter, (0, None))
|
| 713 |
+
if line is None:
|
| 714 |
+
raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nf"{start_multiline_comment_pattern}" without following "{end_multiline_comment_pattern}"')
|
| 715 |
+
if line.strip() != end_multiline_comment_pattern:
|
| 716 |
+
raise Text2qtiError(f'In {self.source_name} on line {n+1}:\nUnexpected content after "{end_multiline_comment_pattern}"')
|
| 717 |
+
n, line = next(n_line_iter, (0, None))
|
| 718 |
+
continue
|
| 719 |
+
elif line.startswith(end_multiline_comment_pattern):
|
| 720 |
+
raise Text2qtiError(f'In {self.source_name} on line {n+1}:\n"{end_multiline_comment_pattern}" without preceding "{start_multiline_comment_pattern}"')
|
| 721 |
+
else:
|
| 722 |
+
action = None
|
| 723 |
+
text = line
|
| 724 |
+
try:
|
| 725 |
+
parse_actions[action](text)
|
| 726 |
+
except Text2qtiError as e:
|
| 727 |
+
if lookahead and n != n_code_start:
|
| 728 |
+
raise Text2qtiError(f'In {self.source_name} on line {n}:\n{e}')
|
| 729 |
+
raise Text2qtiError(f'In {self.source_name} on line {n+1}:\n{e}')
|
| 730 |
+
if not lookahead:
|
| 731 |
+
n, line = next(n_line_iter, (0, None))
|
| 732 |
+
lookahead = False
|
| 733 |
+
if not self.questions_and_delims:
|
| 734 |
+
raise Text2qtiError('No questions were found')
|
| 735 |
+
if self._current_group is not None:
|
| 736 |
+
raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\nQuestion group never ended')
|
| 737 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 738 |
+
if isinstance(last_question_or_delim, Question):
|
| 739 |
+
try:
|
| 740 |
+
last_question_or_delim.finalize()
|
| 741 |
+
except Text2qtiError as e:
|
| 742 |
+
raise Text2qtiError(f'In {self.source_name} on line {len(string.splitlines())}:\n{e}')
|
| 743 |
+
|
| 744 |
+
points_possible = 0
|
| 745 |
+
digests = []
|
| 746 |
+
for x in self.questions_and_delims:
|
| 747 |
+
if isinstance(x, Question):
|
| 748 |
+
points_possible += x.points_possible
|
| 749 |
+
digests.append(x.hash_digest)
|
| 750 |
+
elif isinstance(x, GroupStart):
|
| 751 |
+
points_possible += x.group.points_per_question*x.group.pick
|
| 752 |
+
digests.append(x.group.hash_digest)
|
| 753 |
+
elif isinstance(x, GroupEnd):
|
| 754 |
+
pass
|
| 755 |
+
elif isinstance(x, TextRegion):
|
| 756 |
+
pass
|
| 757 |
+
else:
|
| 758 |
+
raise TypeError
|
| 759 |
+
self.points_possible = points_possible
|
| 760 |
+
h = hashlib.blake2b()
|
| 761 |
+
for digest in sorted(digests):
|
| 762 |
+
h.update(digest)
|
| 763 |
+
self.hash_digest = h.digest()
|
| 764 |
+
self.id = h.hexdigest()[:64]
|
| 765 |
+
finally:
|
| 766 |
+
self.md.finalize()
|
| 767 |
+
|
| 768 |
+
def _run_code(self, executable: str, code: str) -> str:
|
| 769 |
+
if not self.config['run_code_blocks']:
|
| 770 |
+
raise Text2qtiError('Code execution for code blocks is not enabled; use --run-code-blocks, or set run_code_blocks = true in config')
|
| 771 |
+
h = hashlib.blake2b()
|
| 772 |
+
h.update(code.encode('utf8'))
|
| 773 |
+
if platform.system() == 'Windows':
|
| 774 |
+
# Prevent console from appearing for an instant
|
| 775 |
+
startupinfo = subprocess.STARTUPINFO()
|
| 776 |
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
| 777 |
+
else:
|
| 778 |
+
startupinfo = None
|
| 779 |
+
with tempfile.TemporaryDirectory() as tempdir:
|
| 780 |
+
tempdir_path = pathlib.Path(tempdir)
|
| 781 |
+
code_path = tempdir_path / f'{h.hexdigest()[:16]}.code'
|
| 782 |
+
code_path.write_text(code, encoding='utf8')
|
| 783 |
+
if platform.system() == 'Windows':
|
| 784 |
+
# Modify executable since subprocess.Popen() ignores PATH
|
| 785 |
+
# * https://bugs.python.org/issue15451
|
| 786 |
+
# * https://bugs.python.org/issue8557
|
| 787 |
+
which_executable = shutil.which(executable)
|
| 788 |
+
if which_executable is None:
|
| 789 |
+
raise Text2qtiError(f'Failed to execute code (missing executable "{executable}")')
|
| 790 |
+
cmd = [which_executable, code_path.as_posix()]
|
| 791 |
+
else:
|
| 792 |
+
cmd = [executable, code_path.as_posix()]
|
| 793 |
+
try:
|
| 794 |
+
# stdin is needed for GUI because standard file handles can't
|
| 795 |
+
# be inherited
|
| 796 |
+
proc = subprocess.run(cmd,
|
| 797 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE,
|
| 798 |
+
startupinfo=startupinfo)
|
| 799 |
+
except FileNotFoundError as e:
|
| 800 |
+
raise Text2qtiError(f'Failed to execute code (missing executable "{executable}"?):\n{e}')
|
| 801 |
+
except Exception as e:
|
| 802 |
+
raise Text2qtiError(f'Failed to execute code with command "{cmd}":\n{e}')
|
| 803 |
+
# Use io to handle output as if read from a file in terms of newline
|
| 804 |
+
# treatment
|
| 805 |
+
if proc.returncode != 0:
|
| 806 |
+
stderr_str = io.TextIOWrapper(io.BytesIO(proc.stderr),
|
| 807 |
+
encoding=locale.getpreferredencoding(False),
|
| 808 |
+
errors='backslashreplace').read()
|
| 809 |
+
raise Text2qtiError(f'Code execution resulted in errors:\n{"-"*50}\n{stderr_str}\n{"-"*50}')
|
| 810 |
+
try:
|
| 811 |
+
stdout_str = io.TextIOWrapper(io.BytesIO(proc.stdout),
|
| 812 |
+
encoding=locale.getpreferredencoding(False)).read()
|
| 813 |
+
except Exception as e:
|
| 814 |
+
raise Text2qtiError(f'Failed to decode output of executed code:\n{e}')
|
| 815 |
+
return stdout_str
|
| 816 |
+
|
| 817 |
+
def append_quiz_title(self, text: str):
|
| 818 |
+
if any(x is not None for x in (self.shuffle_answers_raw, self.show_correct_answers_raw,
|
| 819 |
+
self.one_question_at_a_time_raw, self.cant_go_back_raw)):
|
| 820 |
+
raise Text2qtiError('Must give quiz title before quiz options')
|
| 821 |
+
if self._next_question_attr:
|
| 822 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 823 |
+
if self.title_raw is not None:
|
| 824 |
+
raise Text2qtiError('Quiz title has already been given')
|
| 825 |
+
if self.questions_and_delims:
|
| 826 |
+
raise Text2qtiError('Must give quiz title before questions')
|
| 827 |
+
if self.description_raw is not None:
|
| 828 |
+
raise Text2qtiError('Must give quiz title before quiz description')
|
| 829 |
+
self.title_raw = text
|
| 830 |
+
self.title_xml = self.md.xml_escape(text)
|
| 831 |
+
|
| 832 |
+
def append_quiz_description(self, text: str):
|
| 833 |
+
if any(x is not None for x in (self.shuffle_answers_raw, self.show_correct_answers_raw,
|
| 834 |
+
self.one_question_at_a_time_raw, self.cant_go_back_raw)):
|
| 835 |
+
raise Text2qtiError('Must give quiz description before quiz options')
|
| 836 |
+
if self._next_question_attr:
|
| 837 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 838 |
+
if self.description_raw is not None:
|
| 839 |
+
raise Text2qtiError('Quiz description has already been given')
|
| 840 |
+
if self.questions_and_delims:
|
| 841 |
+
raise Text2qtiError('Must give quiz description before questions')
|
| 842 |
+
self.description_raw = text
|
| 843 |
+
self.description_html_xml = self.md.md_to_html_xml(text)
|
| 844 |
+
|
| 845 |
+
def append_quiz_shuffle_answers(self, text: str):
|
| 846 |
+
if self._next_question_attr:
|
| 847 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 848 |
+
if self.questions_and_delims:
|
| 849 |
+
raise Text2qtiError('Must give quiz options before questions')
|
| 850 |
+
if self.shuffle_answers_raw is not None:
|
| 851 |
+
raise Text2qtiError('Quiz option "Shuffle answers" has already been set')
|
| 852 |
+
if text not in ('true', 'True', 'false', 'False'):
|
| 853 |
+
raise Text2qtiError('Expected option value "true" or "false"')
|
| 854 |
+
self.shuffle_answers_raw = text
|
| 855 |
+
self.shuffle_answers_xml = text.lower()
|
| 856 |
+
|
| 857 |
+
def append_quiz_show_correct_answers(self, text: str):
|
| 858 |
+
if self._next_question_attr:
|
| 859 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 860 |
+
if self.questions_and_delims:
|
| 861 |
+
raise Text2qtiError('Must give quiz options before questions')
|
| 862 |
+
if self.show_correct_answers_raw is not None:
|
| 863 |
+
raise Text2qtiError('Quiz option "Show correct answers" has already been set')
|
| 864 |
+
if text not in ('true', 'True', 'false', 'False'):
|
| 865 |
+
raise Text2qtiError('Expected option value "true" or "false"')
|
| 866 |
+
self.show_correct_answers_raw = text
|
| 867 |
+
self.show_correct_answers_xml = text.lower()
|
| 868 |
+
|
| 869 |
+
def append_quiz_one_question_at_a_time(self, text: str):
|
| 870 |
+
if self._next_question_attr:
|
| 871 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 872 |
+
if self.questions_and_delims:
|
| 873 |
+
raise Text2qtiError('Must give quiz options before questions')
|
| 874 |
+
if self.one_question_at_a_time_raw is not None:
|
| 875 |
+
raise Text2qtiError('Quiz option "One question at a time" has already been set')
|
| 876 |
+
if text not in ('true', 'True', 'false', 'False'):
|
| 877 |
+
raise Text2qtiError('Expected option value "true" or "false"')
|
| 878 |
+
self.one_question_at_a_time_raw = text
|
| 879 |
+
self.one_question_at_a_time_xml = text.lower()
|
| 880 |
+
|
| 881 |
+
def append_quiz_cant_go_back(self, text: str):
|
| 882 |
+
if self._next_question_attr:
|
| 883 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 884 |
+
if self.questions_and_delims:
|
| 885 |
+
raise Text2qtiError('Must give quiz options before questions')
|
| 886 |
+
if self.cant_go_back_raw is not None:
|
| 887 |
+
raise Text2qtiError('''Quiz option "Can't go back" has already been set''')
|
| 888 |
+
if text not in ('true', 'True', 'false', 'False'):
|
| 889 |
+
raise Text2qtiError('Expected option value "true" or "false"')
|
| 890 |
+
if self.one_question_at_a_time_xml != 'true':
|
| 891 |
+
raise Text2qtiError('''Must set "One question at a time" to "true" before setting "Can't go back"''')
|
| 892 |
+
self.cant_go_back_raw = text
|
| 893 |
+
self.cant_go_back_xml = text.lower()
|
| 894 |
+
|
| 895 |
+
def append_quiz_feedback_is_solution(self, text: str):
|
| 896 |
+
if self._next_question_attr:
|
| 897 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 898 |
+
if self.questions_and_delims:
|
| 899 |
+
raise Text2qtiError('Must give quiz options before questions')
|
| 900 |
+
if self.feedback_is_solution is not None:
|
| 901 |
+
raise Text2qtiError('Quiz option "feedback is solution" has already been set')
|
| 902 |
+
if text in ('true', 'True'):
|
| 903 |
+
self.feedback_is_solution = True
|
| 904 |
+
elif text in ('false', 'False'):
|
| 905 |
+
self.feedback_is_solution = False
|
| 906 |
+
else:
|
| 907 |
+
raise Text2qtiError('Expected option value "true" or "false"')
|
| 908 |
+
|
| 909 |
+
def append_quiz_solutions_sample_groups(self, text: str):
|
| 910 |
+
if self._next_question_attr:
|
| 911 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 912 |
+
if self.questions_and_delims:
|
| 913 |
+
raise Text2qtiError('Must give quiz options before questions')
|
| 914 |
+
if self.solutions_sample_groups is not None:
|
| 915 |
+
raise Text2qtiError('Quiz option "solutions sample groups" has already been set')
|
| 916 |
+
if text in ('true', 'True'):
|
| 917 |
+
self.solutions_sample_groups = True
|
| 918 |
+
elif text in ('false', 'False'):
|
| 919 |
+
self.solutions_sample_groups = False
|
| 920 |
+
else:
|
| 921 |
+
raise Text2qtiError('Expected option value "true" or "false"')
|
| 922 |
+
|
| 923 |
+
def append_quiz_solutions_randomize_groups(self, text: str):
|
| 924 |
+
if self._next_question_attr:
|
| 925 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 926 |
+
if self.questions_and_delims:
|
| 927 |
+
raise Text2qtiError('Must give quiz options before questions')
|
| 928 |
+
if self.solutions_randomize_groups is not None:
|
| 929 |
+
raise Text2qtiError('Quiz option "solutions randomize groups" has already been set')
|
| 930 |
+
if text in ('true', 'True'):
|
| 931 |
+
self.solutions_randomize_groups = True
|
| 932 |
+
elif text in ('false', 'False'):
|
| 933 |
+
self.solutions_randomize_groups = False
|
| 934 |
+
else:
|
| 935 |
+
raise Text2qtiError('Expected option value "true" or "false"')
|
| 936 |
+
|
| 937 |
+
def append_text_title(self, text: str):
|
| 938 |
+
if self._next_question_attr:
|
| 939 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 940 |
+
if self.questions_and_delims:
|
| 941 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 942 |
+
if isinstance(last_question_or_delim, Question):
|
| 943 |
+
last_question_or_delim.finalize()
|
| 944 |
+
text_region = TextRegion(index=len(self.questions_and_delims), md=self.md)
|
| 945 |
+
text_region.set_title(text)
|
| 946 |
+
self.questions_and_delims.append(text_region)
|
| 947 |
+
|
| 948 |
+
def append_text(self, text: str):
|
| 949 |
+
if self._next_question_attr:
|
| 950 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 951 |
+
if self.questions_and_delims:
|
| 952 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 953 |
+
if isinstance(last_question_or_delim, Question):
|
| 954 |
+
last_question_or_delim.finalize()
|
| 955 |
+
if isinstance(last_question_or_delim, TextRegion) and last_question_or_delim.text_raw is None:
|
| 956 |
+
last_question_or_delim.set_text(text)
|
| 957 |
+
else:
|
| 958 |
+
text_region = TextRegion(index=len(self.questions_and_delims), md=self.md)
|
| 959 |
+
text_region.set_text(text)
|
| 960 |
+
self.questions_and_delims.append(text_region)
|
| 961 |
+
else:
|
| 962 |
+
text_region = TextRegion(index=len(self.questions_and_delims), md=self.md)
|
| 963 |
+
text_region.set_text(text)
|
| 964 |
+
self.questions_and_delims.append(text_region)
|
| 965 |
+
|
| 966 |
+
def append_question(self, text: str):
|
| 967 |
+
if self.questions_and_delims:
|
| 968 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 969 |
+
if isinstance(last_question_or_delim, Question):
|
| 970 |
+
last_question_or_delim.finalize()
|
| 971 |
+
question = Question(text,
|
| 972 |
+
quiz=self,
|
| 973 |
+
title=self._next_question_attr.get('title'),
|
| 974 |
+
points=self._next_question_attr.get('points'),
|
| 975 |
+
md=self.md)
|
| 976 |
+
self._next_question_attr = {}
|
| 977 |
+
if question.question_html_xml in self.question_set:
|
| 978 |
+
raise Text2qtiError('Duplicate question')
|
| 979 |
+
self.question_set.add(question.question_html_xml)
|
| 980 |
+
self.questions_and_delims.append(question)
|
| 981 |
+
if self._current_group is not None:
|
| 982 |
+
self._current_group.append_question(question)
|
| 983 |
+
|
| 984 |
+
def append_question_title(self, text: str):
|
| 985 |
+
if 'title' in self._next_question_attr:
|
| 986 |
+
raise Text2qtiError('Title for next question has already been set')
|
| 987 |
+
if 'points' in self._next_question_attr:
|
| 988 |
+
raise Text2qtiError('Title for next question must be set before point value')
|
| 989 |
+
self._next_question_attr['title'] = text
|
| 990 |
+
|
| 991 |
+
def append_question_points(self, text: str):
|
| 992 |
+
if 'points' in self._next_question_attr:
|
| 993 |
+
raise Text2qtiError('Points for next question has already been set')
|
| 994 |
+
self._next_question_attr['points'] = text
|
| 995 |
+
|
| 996 |
+
def append_feedback(self, text: str):
|
| 997 |
+
if self._next_question_attr:
|
| 998 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 999 |
+
if not self.questions_and_delims:
|
| 1000 |
+
raise Text2qtiError('Cannot have feedback without a question')
|
| 1001 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1002 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1003 |
+
raise Text2qtiError('Cannot have feedback without a question')
|
| 1004 |
+
last_question_or_delim.append_feedback(text)
|
| 1005 |
+
|
| 1006 |
+
def append_correct_feedback(self, text: str):
|
| 1007 |
+
if self._next_question_attr:
|
| 1008 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1009 |
+
if not self.questions_and_delims:
|
| 1010 |
+
raise Text2qtiError('Cannot have feedback without a question')
|
| 1011 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1012 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1013 |
+
raise Text2qtiError('Cannot have feedback without a question')
|
| 1014 |
+
last_question_or_delim.append_correct_feedback(text)
|
| 1015 |
+
|
| 1016 |
+
def append_incorrect_feedback(self, text: str):
|
| 1017 |
+
if self._next_question_attr:
|
| 1018 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1019 |
+
if not self.questions_and_delims:
|
| 1020 |
+
raise Text2qtiError('Cannot have feedback without a question')
|
| 1021 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1022 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1023 |
+
raise Text2qtiError('Cannot have feedback without a question')
|
| 1024 |
+
last_question_or_delim.append_incorrect_feedback(text)
|
| 1025 |
+
|
| 1026 |
+
def append_solution(self, text: str):
|
| 1027 |
+
if self.feedback_is_solution:
|
| 1028 |
+
raise Text2qtiError('Quiz option "feedback is solution" is "true"; '
|
| 1029 |
+
'"!" solution syntax is disabled and solutions must be provided as feedback ("...") rather than separately')
|
| 1030 |
+
if self._next_question_attr:
|
| 1031 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1032 |
+
if not self.questions_and_delims:
|
| 1033 |
+
raise Text2qtiError('Cannot have a solution without a question')
|
| 1034 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1035 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1036 |
+
raise Text2qtiError('Cannot have a solution without a question')
|
| 1037 |
+
last_question_or_delim.append_solution(text)
|
| 1038 |
+
|
| 1039 |
+
def append_mctf_correct_choice(self, text: str):
|
| 1040 |
+
if self._next_question_attr:
|
| 1041 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1042 |
+
if not self.questions_and_delims:
|
| 1043 |
+
raise Text2qtiError('Cannot have a choice without a question')
|
| 1044 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1045 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1046 |
+
raise Text2qtiError('Cannot have a choice without a question')
|
| 1047 |
+
last_question_or_delim.append_mctf_correct_choice(text)
|
| 1048 |
+
|
| 1049 |
+
def append_mctf_incorrect_choice(self, text: str):
|
| 1050 |
+
if self._next_question_attr:
|
| 1051 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1052 |
+
if not self.questions_and_delims:
|
| 1053 |
+
raise Text2qtiError('Cannot have a choice without a question')
|
| 1054 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1055 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1056 |
+
raise Text2qtiError('Cannot have a choice without a question')
|
| 1057 |
+
last_question_or_delim.append_mctf_incorrect_choice(text)
|
| 1058 |
+
|
| 1059 |
+
def append_shortans_correct_choice(self, text: str):
|
| 1060 |
+
if self._next_question_attr:
|
| 1061 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1062 |
+
if not self.questions_and_delims:
|
| 1063 |
+
raise Text2qtiError('Cannot have an answer without a question')
|
| 1064 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1065 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1066 |
+
raise Text2qtiError('Cannot have an answer without a question')
|
| 1067 |
+
last_question_or_delim.append_shortans_correct_choice(text)
|
| 1068 |
+
|
| 1069 |
+
def append_multans_correct_choice(self, text: str):
|
| 1070 |
+
if self._next_question_attr:
|
| 1071 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1072 |
+
if not self.questions_and_delims:
|
| 1073 |
+
raise Text2qtiError('Cannot have a choice without a question')
|
| 1074 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1075 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1076 |
+
raise Text2qtiError('Cannot have a choice without a question')
|
| 1077 |
+
last_question_or_delim.append_multans_correct_choice(text)
|
| 1078 |
+
|
| 1079 |
+
def append_multans_incorrect_choice(self, text: str):
|
| 1080 |
+
if self._next_question_attr:
|
| 1081 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1082 |
+
if not self.questions_and_delims:
|
| 1083 |
+
raise Text2qtiError('Cannot have a choice without a question')
|
| 1084 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1085 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1086 |
+
raise Text2qtiError('Cannot have a choice without a question')
|
| 1087 |
+
last_question_or_delim.append_multans_incorrect_choice(text)
|
| 1088 |
+
|
| 1089 |
+
def append_essay(self, text: str):
|
| 1090 |
+
if self._next_question_attr:
|
| 1091 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1092 |
+
if not self.questions_and_delims:
|
| 1093 |
+
raise Text2qtiError('Cannot have an essay response without a question')
|
| 1094 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1095 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1096 |
+
raise Text2qtiError('Cannot have an essay response without a question')
|
| 1097 |
+
last_question_or_delim.append_essay(text)
|
| 1098 |
+
|
| 1099 |
+
def append_upload(self, text: str):
|
| 1100 |
+
if self._next_question_attr:
|
| 1101 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1102 |
+
if not self.questions_and_delims:
|
| 1103 |
+
raise Text2qtiError('Cannot have an upload response without a question')
|
| 1104 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1105 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1106 |
+
raise Text2qtiError('Cannot have an upload response without a question')
|
| 1107 |
+
last_question_or_delim.append_upload(text)
|
| 1108 |
+
|
| 1109 |
+
def append_numerical(self, text: str):
|
| 1110 |
+
if self._next_question_attr:
|
| 1111 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1112 |
+
if not self.questions_and_delims:
|
| 1113 |
+
raise Text2qtiError('Cannot have a numerical response without a question')
|
| 1114 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1115 |
+
if not isinstance(last_question_or_delim, Question):
|
| 1116 |
+
raise Text2qtiError('Cannot have a numerical response without a question')
|
| 1117 |
+
last_question_or_delim.append_numerical(text)
|
| 1118 |
+
|
| 1119 |
+
def append_start_group(self, text: str):
|
| 1120 |
+
if self._next_question_attr:
|
| 1121 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1122 |
+
if text:
|
| 1123 |
+
raise ValueError
|
| 1124 |
+
if self._current_group is not None:
|
| 1125 |
+
raise Text2qtiError('Question groups cannot be nested')
|
| 1126 |
+
if self.questions_and_delims:
|
| 1127 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1128 |
+
if isinstance(last_question_or_delim, Question):
|
| 1129 |
+
last_question_or_delim.finalize()
|
| 1130 |
+
group = Group()
|
| 1131 |
+
self._current_group = group
|
| 1132 |
+
self.questions_and_delims.append(GroupStart(group))
|
| 1133 |
+
|
| 1134 |
+
def append_end_group(self, text: str):
|
| 1135 |
+
if self._next_question_attr:
|
| 1136 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1137 |
+
if text:
|
| 1138 |
+
raise ValueError
|
| 1139 |
+
if self._current_group is None:
|
| 1140 |
+
raise Text2qtiError('No question group to end')
|
| 1141 |
+
if self.questions_and_delims:
|
| 1142 |
+
last_question_or_delim = self.questions_and_delims[-1]
|
| 1143 |
+
if isinstance(last_question_or_delim, Question):
|
| 1144 |
+
last_question_or_delim.finalize()
|
| 1145 |
+
self._current_group.finalize()
|
| 1146 |
+
self.questions_and_delims.append(GroupEnd(self._current_group))
|
| 1147 |
+
self._current_group = None
|
| 1148 |
+
|
| 1149 |
+
def append_group_pick(self, text: str):
|
| 1150 |
+
if self._next_question_attr:
|
| 1151 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1152 |
+
if self._current_group is None:
|
| 1153 |
+
raise Text2qtiError('No question group for setting properties')
|
| 1154 |
+
self._current_group.append_group_pick(text)
|
| 1155 |
+
|
| 1156 |
+
def append_group_solutions_pick(self, text: str):
|
| 1157 |
+
if self._next_question_attr:
|
| 1158 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1159 |
+
if self._current_group is None:
|
| 1160 |
+
raise Text2qtiError('No question group for setting properties')
|
| 1161 |
+
self._current_group.append_group_solutions_pick(text)
|
| 1162 |
+
|
| 1163 |
+
def append_group_points_per_question(self, text: str):
|
| 1164 |
+
if self._next_question_attr:
|
| 1165 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1166 |
+
if self._current_group is None:
|
| 1167 |
+
raise Text2qtiError('No question group for setting properties')
|
| 1168 |
+
self._current_group.append_group_points_per_question(text)
|
| 1169 |
+
|
| 1170 |
+
def append_start_code(self, text: str):
|
| 1171 |
+
raise Text2qtiError('Invalid code block start')
|
| 1172 |
+
|
| 1173 |
+
def append_end_code(self, text: str):
|
| 1174 |
+
raise Text2qtiError('Code block end missing code block start')
|
| 1175 |
+
|
| 1176 |
+
def append_unknown(self, text: str):
|
| 1177 |
+
if self._next_question_attr:
|
| 1178 |
+
raise Text2qtiError('Expected question; question title and/or points were set but not used')
|
| 1179 |
+
if text and not text.isspace():
|
| 1180 |
+
match = start_missing_whitespace_re.match(text)
|
| 1181 |
+
if match:
|
| 1182 |
+
raise Text2qtiError(f'Missing whitespace after "{match.group().strip()}"')
|
| 1183 |
+
match = start_missing_content_re.match(text)
|
| 1184 |
+
if match:
|
| 1185 |
+
raise Text2qtiError(f'Missing content after "{match.group().strip()}"')
|
| 1186 |
+
raise Text2qtiError(f'Syntax error; unexpected text, or incorrect indentation for a wrapped paragraph:\n"{text}"')
|
text2qti/version.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
|
| 3 |
+
from .fmtversion import get_version_plus_info
|
| 4 |
+
__version__, __version_info__ = get_version_plus_info(0, 7, 0, 'dev', 1)
|
text2qti/xml_assessment.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
from .quiz import Quiz, Question, GroupStart, GroupEnd, TextRegion
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
BEFORE_ITEMS = '''\
|
| 15 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 16 |
+
<questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/ims_qtiasiv1p2 http://www.imsglobal.org/xsd/ims_qtiasiv1p2p1.xsd">
|
| 17 |
+
<assessment ident="{assessment_identifier}" title="{title}">
|
| 18 |
+
<qtimetadata>
|
| 19 |
+
<qtimetadatafield>
|
| 20 |
+
<fieldlabel>cc_maxattempts</fieldlabel>
|
| 21 |
+
<fieldentry>1</fieldentry>
|
| 22 |
+
</qtimetadatafield>
|
| 23 |
+
</qtimetadata>
|
| 24 |
+
<section ident="root_section">
|
| 25 |
+
'''
|
| 26 |
+
|
| 27 |
+
AFTER_ITEMS = '''\
|
| 28 |
+
</section>
|
| 29 |
+
</assessment>
|
| 30 |
+
</questestinterop>
|
| 31 |
+
'''
|
| 32 |
+
|
| 33 |
+
GROUP_START = '''\
|
| 34 |
+
<section ident="{ident}" title="{group_title}">
|
| 35 |
+
<selection_ordering>
|
| 36 |
+
<selection>
|
| 37 |
+
<selection_number>{pick}</selection_number>
|
| 38 |
+
<selection_extension>
|
| 39 |
+
<points_per_item>{points_per_item}</points_per_item>
|
| 40 |
+
</selection_extension>
|
| 41 |
+
</selection>
|
| 42 |
+
</selection_ordering>
|
| 43 |
+
'''
|
| 44 |
+
|
| 45 |
+
GROUP_END = '''\
|
| 46 |
+
</section>
|
| 47 |
+
'''
|
| 48 |
+
|
| 49 |
+
TEXT = '''\
|
| 50 |
+
<item ident="{ident}" title="{text_title_xml}">
|
| 51 |
+
<itemmetadata>
|
| 52 |
+
<qtimetadata>
|
| 53 |
+
<qtimetadatafield>
|
| 54 |
+
<fieldlabel>question_type</fieldlabel>
|
| 55 |
+
<fieldentry>text_only_question</fieldentry>
|
| 56 |
+
</qtimetadatafield>
|
| 57 |
+
<qtimetadatafield>
|
| 58 |
+
<fieldlabel>points_possible</fieldlabel>
|
| 59 |
+
<fieldentry>0</fieldentry>
|
| 60 |
+
</qtimetadatafield>
|
| 61 |
+
<qtimetadatafield>
|
| 62 |
+
<fieldlabel>original_answer_ids</fieldlabel>
|
| 63 |
+
<fieldentry></fieldentry>
|
| 64 |
+
</qtimetadatafield>
|
| 65 |
+
<qtimetadatafield>
|
| 66 |
+
<fieldlabel>assessment_question_identifierref</fieldlabel>
|
| 67 |
+
<fieldentry>{assessment_question_identifierref}</fieldentry>
|
| 68 |
+
</qtimetadatafield>
|
| 69 |
+
</qtimetadata>
|
| 70 |
+
</itemmetadata>
|
| 71 |
+
<presentation>
|
| 72 |
+
<material>
|
| 73 |
+
<mattext texttype="text/html">{text_html_xml}</mattext>
|
| 74 |
+
</material>
|
| 75 |
+
</presentation>
|
| 76 |
+
</item>
|
| 77 |
+
'''
|
| 78 |
+
|
| 79 |
+
START_ITEM = '''\
|
| 80 |
+
<item ident="{question_identifier}" title="{question_title}">
|
| 81 |
+
'''
|
| 82 |
+
|
| 83 |
+
END_ITEM = '''\
|
| 84 |
+
</item>
|
| 85 |
+
'''
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
ITEM_METADATA_MCTF_SHORTANS_MULTANS_NUM = '''\
|
| 89 |
+
<itemmetadata>
|
| 90 |
+
<qtimetadata>
|
| 91 |
+
<qtimetadatafield>
|
| 92 |
+
<fieldlabel>question_type</fieldlabel>
|
| 93 |
+
<fieldentry>{question_type}</fieldentry>
|
| 94 |
+
</qtimetadatafield>
|
| 95 |
+
<qtimetadatafield>
|
| 96 |
+
<fieldlabel>points_possible</fieldlabel>
|
| 97 |
+
<fieldentry>{points_possible}</fieldentry>
|
| 98 |
+
</qtimetadatafield>
|
| 99 |
+
<qtimetadatafield>
|
| 100 |
+
<fieldlabel>original_answer_ids</fieldlabel>
|
| 101 |
+
<fieldentry>{original_answer_ids}</fieldentry>
|
| 102 |
+
</qtimetadatafield>
|
| 103 |
+
<qtimetadatafield>
|
| 104 |
+
<fieldlabel>assessment_question_identifierref</fieldlabel>
|
| 105 |
+
<fieldentry>{assessment_question_identifierref}</fieldentry>
|
| 106 |
+
</qtimetadatafield>
|
| 107 |
+
</qtimetadata>
|
| 108 |
+
</itemmetadata>
|
| 109 |
+
'''
|
| 110 |
+
|
| 111 |
+
ITEM_METADATA_ESSAY = ITEM_METADATA_MCTF_SHORTANS_MULTANS_NUM.replace('{original_answer_ids}', '')
|
| 112 |
+
|
| 113 |
+
ITEM_METADATA_UPLOAD = ITEM_METADATA_ESSAY
|
| 114 |
+
|
| 115 |
+
ITEM_PRESENTATION_MCTF = '''\
|
| 116 |
+
<presentation>
|
| 117 |
+
<material>
|
| 118 |
+
<mattext texttype="text/html">{question_html_xml}</mattext>
|
| 119 |
+
</material>
|
| 120 |
+
<response_lid ident="response1" rcardinality="Single">
|
| 121 |
+
<render_choice>
|
| 122 |
+
{choices}
|
| 123 |
+
</render_choice>
|
| 124 |
+
</response_lid>
|
| 125 |
+
</presentation>
|
| 126 |
+
'''
|
| 127 |
+
|
| 128 |
+
ITEM_PRESENTATION_MCTF_CHOICE = '''\
|
| 129 |
+
<response_label ident="{ident}">
|
| 130 |
+
<material>
|
| 131 |
+
<mattext texttype="text/html">{choice_html_xml}</mattext>
|
| 132 |
+
</material>
|
| 133 |
+
</response_label>'''
|
| 134 |
+
|
| 135 |
+
ITEM_PRESENTATION_MULTANS = ITEM_PRESENTATION_MCTF.replace('Single', 'Multiple')
|
| 136 |
+
|
| 137 |
+
ITEM_PRESENTATION_MULTANS_CHOICE = ITEM_PRESENTATION_MCTF_CHOICE
|
| 138 |
+
|
| 139 |
+
ITEM_PRESENTATION_SHORTANS = '''\
|
| 140 |
+
<presentation>
|
| 141 |
+
<material>
|
| 142 |
+
<mattext texttype="text/html">{question_html_xml}</mattext>
|
| 143 |
+
</material>
|
| 144 |
+
<response_str ident="response1" rcardinality="Single">
|
| 145 |
+
<render_fib>
|
| 146 |
+
<response_label ident="answer1" rshuffle="No"/>
|
| 147 |
+
</render_fib>
|
| 148 |
+
</response_str>
|
| 149 |
+
</presentation>
|
| 150 |
+
'''
|
| 151 |
+
|
| 152 |
+
ITEM_PRESENTATION_ESSAY = '''\
|
| 153 |
+
<presentation>
|
| 154 |
+
<material>
|
| 155 |
+
<mattext texttype="text/html">{question_html_xml}</mattext>
|
| 156 |
+
</material>
|
| 157 |
+
<response_str ident="response1" rcardinality="Single">
|
| 158 |
+
<render_fib>
|
| 159 |
+
<response_label ident="answer1" rshuffle="No"/>
|
| 160 |
+
</render_fib>
|
| 161 |
+
</response_str>
|
| 162 |
+
</presentation>
|
| 163 |
+
'''
|
| 164 |
+
|
| 165 |
+
ITEM_PRESENTATION_UPLOAD = '''\
|
| 166 |
+
<presentation>
|
| 167 |
+
<material>
|
| 168 |
+
<mattext texttype="text/html">{question_html_xml}</mattext>
|
| 169 |
+
</material>
|
| 170 |
+
</presentation>
|
| 171 |
+
'''
|
| 172 |
+
|
| 173 |
+
ITEM_PRESENTATION_NUM = '''\
|
| 174 |
+
<presentation>
|
| 175 |
+
<material>
|
| 176 |
+
<mattext texttype="text/html">{question_html_xml}</mattext>
|
| 177 |
+
</material>
|
| 178 |
+
<response_str ident="response1" rcardinality="Single">
|
| 179 |
+
<render_fib fibtype="Decimal">
|
| 180 |
+
<response_label ident="answer1"/>
|
| 181 |
+
</render_fib>
|
| 182 |
+
</response_str>
|
| 183 |
+
</presentation>
|
| 184 |
+
'''
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
ITEM_RESPROCESSING_START = '''\
|
| 188 |
+
<resprocessing>
|
| 189 |
+
<outcomes>
|
| 190 |
+
<decvar maxvalue="100" minvalue="0" varname="SCORE" vartype="Decimal"/>
|
| 191 |
+
</outcomes>
|
| 192 |
+
'''
|
| 193 |
+
|
| 194 |
+
ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK = '''\
|
| 195 |
+
<respcondition continue="Yes">
|
| 196 |
+
<conditionvar>
|
| 197 |
+
<other/>
|
| 198 |
+
</conditionvar>
|
| 199 |
+
<displayfeedback feedbacktype="Response" linkrefid="general_fb"/>
|
| 200 |
+
</respcondition>
|
| 201 |
+
'''
|
| 202 |
+
|
| 203 |
+
ITEM_RESPROCESSING_MCTF_CHOICE_FEEDBACK = '''\
|
| 204 |
+
<respcondition continue="Yes">
|
| 205 |
+
<conditionvar>
|
| 206 |
+
<varequal respident="response1">{ident}</varequal>
|
| 207 |
+
</conditionvar>
|
| 208 |
+
<displayfeedback feedbacktype="Response" linkrefid="{ident}_fb"/>
|
| 209 |
+
</respcondition>
|
| 210 |
+
'''
|
| 211 |
+
|
| 212 |
+
ITEM_RESPROCESSING_MCTF_SET_CORRECT_WITH_FEEDBACK = '''\
|
| 213 |
+
<respcondition continue="No">
|
| 214 |
+
<conditionvar>
|
| 215 |
+
<varequal respident="response1">{ident}</varequal>
|
| 216 |
+
</conditionvar>
|
| 217 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 218 |
+
<displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
|
| 219 |
+
</respcondition>
|
| 220 |
+
'''
|
| 221 |
+
|
| 222 |
+
ITEM_RESPROCESSING_MCTF_SET_CORRECT_NO_FEEDBACK = '''\
|
| 223 |
+
<respcondition continue="No">
|
| 224 |
+
<conditionvar>
|
| 225 |
+
<varequal respident="response1">{ident}</varequal>
|
| 226 |
+
</conditionvar>
|
| 227 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 228 |
+
</respcondition>
|
| 229 |
+
'''
|
| 230 |
+
|
| 231 |
+
ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK = '''\
|
| 232 |
+
<respcondition continue="Yes">
|
| 233 |
+
<conditionvar>
|
| 234 |
+
<other/>
|
| 235 |
+
</conditionvar>
|
| 236 |
+
<displayfeedback feedbacktype="Response" linkrefid="general_incorrect_fb"/>
|
| 237 |
+
</respcondition>
|
| 238 |
+
'''
|
| 239 |
+
|
| 240 |
+
ITEM_RESPROCESSING_SHORTANS_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
|
| 241 |
+
|
| 242 |
+
ITEM_RESPROCESSING_SHORTANS_CHOICE_FEEDBACK = '''\
|
| 243 |
+
<respcondition continue="Yes">
|
| 244 |
+
<conditionvar>
|
| 245 |
+
<varequal respident="response1">{answer_xml}</varequal>
|
| 246 |
+
</conditionvar>
|
| 247 |
+
<displayfeedback feedbacktype="Response" linkrefid="{ident}_fb"/>
|
| 248 |
+
</respcondition>
|
| 249 |
+
'''
|
| 250 |
+
|
| 251 |
+
ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_WITH_FEEDBACK = '''\
|
| 252 |
+
<respcondition continue="No">
|
| 253 |
+
<conditionvar>
|
| 254 |
+
{varequal}
|
| 255 |
+
</conditionvar>
|
| 256 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 257 |
+
<displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
|
| 258 |
+
</respcondition>
|
| 259 |
+
'''
|
| 260 |
+
|
| 261 |
+
ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_NO_FEEDBACK = '''\
|
| 262 |
+
<respcondition continue="No">
|
| 263 |
+
<conditionvar>
|
| 264 |
+
{varequal}
|
| 265 |
+
</conditionvar>
|
| 266 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 267 |
+
</respcondition>
|
| 268 |
+
'''
|
| 269 |
+
|
| 270 |
+
ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_VAREQUAL = '''\
|
| 271 |
+
<varequal respident="response1">{answer_xml}</varequal>'''
|
| 272 |
+
|
| 273 |
+
ITEM_RESPROCESSING_SHORTANS_INCORRECT_FEEDBACK = ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK
|
| 274 |
+
|
| 275 |
+
ITEM_RESPROCESSING_MULTANS_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
|
| 276 |
+
|
| 277 |
+
ITEM_RESPROCESSING_MULTANS_CHOICE_FEEDBACK = ITEM_RESPROCESSING_MCTF_CHOICE_FEEDBACK
|
| 278 |
+
|
| 279 |
+
ITEM_RESPROCESSING_MULTANS_SET_CORRECT_WITH_FEEDBACK = '''\
|
| 280 |
+
<respcondition continue="No">
|
| 281 |
+
<conditionvar>
|
| 282 |
+
<and>
|
| 283 |
+
{varequal}
|
| 284 |
+
</and>
|
| 285 |
+
</conditionvar>
|
| 286 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 287 |
+
<displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
|
| 288 |
+
</respcondition>
|
| 289 |
+
'''
|
| 290 |
+
|
| 291 |
+
ITEM_RESPROCESSING_MULTANS_SET_CORRECT_NO_FEEDBACK = '''\
|
| 292 |
+
<respcondition continue="No">
|
| 293 |
+
<conditionvar>
|
| 294 |
+
<and>
|
| 295 |
+
{varequal}
|
| 296 |
+
</and>
|
| 297 |
+
</conditionvar>
|
| 298 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 299 |
+
</respcondition>
|
| 300 |
+
'''
|
| 301 |
+
|
| 302 |
+
ITEM_RESPROCESSING_MULTANS_SET_CORRECT_VAREQUAL_CORRECT = '''\
|
| 303 |
+
<varequal respident="response1">{ident}</varequal>'''
|
| 304 |
+
|
| 305 |
+
ITEM_RESPROCESSING_MULTANS_SET_CORRECT_VAREQUAL_INCORRECT = '''\
|
| 306 |
+
<not>
|
| 307 |
+
<varequal respident="response1">{ident}</varequal>
|
| 308 |
+
</not>'''
|
| 309 |
+
|
| 310 |
+
ITEM_RESPROCESSING_MULTANS_INCORRECT_FEEDBACK = ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK
|
| 311 |
+
|
| 312 |
+
ITEM_RESPROCESSING_ESSAY_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
|
| 313 |
+
|
| 314 |
+
ITEM_RESPROCESSING_UPLOAD_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
|
| 315 |
+
|
| 316 |
+
ITEM_RESPROCESSING_NUM_GENERAL_FEEDBACK = ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK
|
| 317 |
+
|
| 318 |
+
ITEM_RESPROCESSING_NUM_RANGE_SET_CORRECT_WITH_FEEDBACK = '''\
|
| 319 |
+
<respcondition continue="No">
|
| 320 |
+
<conditionvar>
|
| 321 |
+
<vargte respident="response1">{num_min}</vargte>
|
| 322 |
+
<varlte respident="response1">{num_max}</varlte>
|
| 323 |
+
</conditionvar>
|
| 324 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 325 |
+
<displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
|
| 326 |
+
</respcondition>
|
| 327 |
+
'''
|
| 328 |
+
|
| 329 |
+
ITEM_RESPROCESSING_NUM_RANGE_SET_CORRECT_NO_FEEDBACK = '''\
|
| 330 |
+
<respcondition continue="No">
|
| 331 |
+
<conditionvar>
|
| 332 |
+
<vargte respident="response1">{num_min}</vargte>
|
| 333 |
+
<varlte respident="response1">{num_max}</varlte>
|
| 334 |
+
</conditionvar>
|
| 335 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 336 |
+
</respcondition>
|
| 337 |
+
'''
|
| 338 |
+
|
| 339 |
+
ITEM_RESPROCESSING_NUM_EXACT_SET_CORRECT_WITH_FEEDBACK = '''\
|
| 340 |
+
<respcondition continue="No">
|
| 341 |
+
<conditionvar>
|
| 342 |
+
<or>
|
| 343 |
+
<varequal respident="response1">{num_exact}</varequal>
|
| 344 |
+
<and>
|
| 345 |
+
<vargte respident="response1">{num_min}</vargte>
|
| 346 |
+
<varlte respident="response1">{num_max}</varlte>
|
| 347 |
+
</and>
|
| 348 |
+
</or>
|
| 349 |
+
</conditionvar>
|
| 350 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 351 |
+
<displayfeedback feedbacktype="Response" linkrefid="correct_fb"/>
|
| 352 |
+
</respcondition>
|
| 353 |
+
'''
|
| 354 |
+
|
| 355 |
+
ITEM_RESPROCESSING_NUM_EXACT_SET_CORRECT_NO_FEEDBACK = '''\
|
| 356 |
+
<respcondition continue="No">
|
| 357 |
+
<conditionvar>
|
| 358 |
+
<or>
|
| 359 |
+
<varequal respident="response1">{num_exact}</varequal>
|
| 360 |
+
<and>
|
| 361 |
+
<vargte respident="response1">{num_min}</vargte>
|
| 362 |
+
<varlte respident="response1">{num_max}</varlte>
|
| 363 |
+
</and>
|
| 364 |
+
</or>
|
| 365 |
+
</conditionvar>
|
| 366 |
+
<setvar action="Set" varname="SCORE">100</setvar>
|
| 367 |
+
</respcondition>
|
| 368 |
+
'''
|
| 369 |
+
|
| 370 |
+
ITEM_RESPROCESSING_NUM_INCORRECT_FEEDBACK = ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK
|
| 371 |
+
|
| 372 |
+
ITEM_RESPROCESSING_ESSAY = '''\
|
| 373 |
+
<respcondition continue="No">
|
| 374 |
+
<conditionvar>
|
| 375 |
+
<other/>
|
| 376 |
+
</conditionvar>
|
| 377 |
+
</respcondition>
|
| 378 |
+
'''
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
ITEM_RESPROCESSING_END = '''\
|
| 383 |
+
</resprocessing>
|
| 384 |
+
'''
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_GENERAL = '''\
|
| 388 |
+
<itemfeedback ident="general_fb">
|
| 389 |
+
<flow_mat>
|
| 390 |
+
<material>
|
| 391 |
+
<mattext texttype="text/html">{feedback}</mattext>
|
| 392 |
+
</material>
|
| 393 |
+
</flow_mat>
|
| 394 |
+
</itemfeedback>
|
| 395 |
+
'''
|
| 396 |
+
|
| 397 |
+
ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_CORRECT = '''\
|
| 398 |
+
<itemfeedback ident="correct_fb">
|
| 399 |
+
<flow_mat>
|
| 400 |
+
<material>
|
| 401 |
+
<mattext texttype="text/html">{feedback}</mattext>
|
| 402 |
+
</material>
|
| 403 |
+
</flow_mat>
|
| 404 |
+
</itemfeedback>
|
| 405 |
+
'''
|
| 406 |
+
|
| 407 |
+
ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_INCORRECT = '''\
|
| 408 |
+
<itemfeedback ident="general_incorrect_fb">
|
| 409 |
+
<flow_mat>
|
| 410 |
+
<material>
|
| 411 |
+
<mattext texttype="text/html">{feedback}</mattext>
|
| 412 |
+
</material>
|
| 413 |
+
</flow_mat>
|
| 414 |
+
</itemfeedback>
|
| 415 |
+
'''
|
| 416 |
+
|
| 417 |
+
ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_INDIVIDUAL = '''\
|
| 418 |
+
<itemfeedback ident="{ident}_fb">
|
| 419 |
+
<flow_mat>
|
| 420 |
+
<material>
|
| 421 |
+
<mattext texttype="text/html">{feedback}</mattext>
|
| 422 |
+
</material>
|
| 423 |
+
</flow_mat>
|
| 424 |
+
</itemfeedback>
|
| 425 |
+
'''
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def assessment(*, quiz: Quiz, assessment_identifier: str, title_xml: str) -> str:
|
| 431 |
+
'''
|
| 432 |
+
Generate assessment XML from Quiz.
|
| 433 |
+
'''
|
| 434 |
+
xml = []
|
| 435 |
+
xml.append(BEFORE_ITEMS.format(assessment_identifier=assessment_identifier,
|
| 436 |
+
title=title_xml))
|
| 437 |
+
for question_or_delim in quiz.questions_and_delims:
|
| 438 |
+
if isinstance(question_or_delim, TextRegion):
|
| 439 |
+
xml.append(TEXT.format(ident=f'text2qti_text_{question_or_delim.id}',
|
| 440 |
+
text_title_xml=question_or_delim.title_xml,
|
| 441 |
+
assessment_question_identifierref=f'text2qti_question_ref_{question_or_delim.id}',
|
| 442 |
+
text_html_xml=question_or_delim.text_html_xml))
|
| 443 |
+
continue
|
| 444 |
+
if isinstance(question_or_delim, GroupStart):
|
| 445 |
+
xml.append(GROUP_START.format(ident=f'text2qti_group_{question_or_delim.group.id}',
|
| 446 |
+
group_title=question_or_delim.group.title_xml,
|
| 447 |
+
pick=question_or_delim.group.pick,
|
| 448 |
+
points_per_item=question_or_delim.group.points_per_question))
|
| 449 |
+
continue
|
| 450 |
+
if isinstance(question_or_delim, GroupEnd):
|
| 451 |
+
xml.append(GROUP_END)
|
| 452 |
+
continue
|
| 453 |
+
if not isinstance(question_or_delim, Question):
|
| 454 |
+
raise TypeError
|
| 455 |
+
question = question_or_delim
|
| 456 |
+
|
| 457 |
+
xml.append(START_ITEM.format(question_identifier=f'text2qti_question_{question.id}',
|
| 458 |
+
question_title=question.title_xml))
|
| 459 |
+
|
| 460 |
+
if question.type in ('true_false_question', 'multiple_choice_question',
|
| 461 |
+
'short_answer_question', 'multiple_answers_question'):
|
| 462 |
+
item_metadata = ITEM_METADATA_MCTF_SHORTANS_MULTANS_NUM
|
| 463 |
+
original_answer_ids = ','.join(f'text2qti_choice_{c.id}' for c in question.choices)
|
| 464 |
+
elif question.type == 'numerical_question':
|
| 465 |
+
item_metadata = ITEM_METADATA_MCTF_SHORTANS_MULTANS_NUM
|
| 466 |
+
original_answer_ids = f'text2qti_numerical_{question.id}'
|
| 467 |
+
elif question.type == 'essay_question':
|
| 468 |
+
item_metadata = ITEM_METADATA_ESSAY
|
| 469 |
+
original_answer_ids = f'text2qti_essay_{question.id}'
|
| 470 |
+
elif question.type == 'file_upload_question':
|
| 471 |
+
item_metadata = ITEM_METADATA_UPLOAD
|
| 472 |
+
original_answer_ids = f'text2qti_upload_{question.id}'
|
| 473 |
+
else:
|
| 474 |
+
raise ValueError
|
| 475 |
+
xml.append(item_metadata.format(question_type=question.type,
|
| 476 |
+
points_possible=question.points_possible,
|
| 477 |
+
original_answer_ids=original_answer_ids,
|
| 478 |
+
assessment_question_identifierref=f'text2qti_question_ref_{question.id}'))
|
| 479 |
+
|
| 480 |
+
if question.type in ('true_false_question', 'multiple_choice_question', 'multiple_answers_question'):
|
| 481 |
+
if question.type in ('true_false_question', 'multiple_choice_question'):
|
| 482 |
+
item_presentation_choice = ITEM_PRESENTATION_MCTF_CHOICE
|
| 483 |
+
item_presentation = ITEM_PRESENTATION_MCTF
|
| 484 |
+
elif question.type == 'multiple_answers_question':
|
| 485 |
+
item_presentation_choice = ITEM_PRESENTATION_MULTANS_CHOICE
|
| 486 |
+
item_presentation = ITEM_PRESENTATION_MULTANS
|
| 487 |
+
else:
|
| 488 |
+
raise ValueError
|
| 489 |
+
choices = '\n'.join(item_presentation_choice.format(ident=f'text2qti_choice_{c.id}', choice_html_xml=c.choice_html_xml)
|
| 490 |
+
for c in question.choices)
|
| 491 |
+
xml.append(item_presentation.format(question_html_xml=question.question_html_xml, choices=choices))
|
| 492 |
+
elif question.type == 'short_answer_question':
|
| 493 |
+
xml.append(ITEM_PRESENTATION_SHORTANS.format(question_html_xml=question.question_html_xml))
|
| 494 |
+
elif question.type == 'numerical_question':
|
| 495 |
+
xml.append(ITEM_PRESENTATION_NUM.format(question_html_xml=question.question_html_xml))
|
| 496 |
+
elif question.type == 'essay_question':
|
| 497 |
+
xml.append(ITEM_PRESENTATION_ESSAY.format(question_html_xml=question.question_html_xml))
|
| 498 |
+
elif question.type == 'file_upload_question':
|
| 499 |
+
xml.append(ITEM_PRESENTATION_UPLOAD.format(question_html_xml=question.question_html_xml))
|
| 500 |
+
else:
|
| 501 |
+
raise ValueError
|
| 502 |
+
|
| 503 |
+
if question.type in ('true_false_question', 'multiple_choice_question'):
|
| 504 |
+
correct_choice = None
|
| 505 |
+
for choice in question.choices:
|
| 506 |
+
if choice.correct:
|
| 507 |
+
correct_choice = choice
|
| 508 |
+
break
|
| 509 |
+
if correct_choice is None:
|
| 510 |
+
raise TypeError
|
| 511 |
+
resprocessing = []
|
| 512 |
+
resprocessing.append(ITEM_RESPROCESSING_START)
|
| 513 |
+
if question.feedback_raw is not None:
|
| 514 |
+
resprocessing.append(ITEM_RESPROCESSING_MCTF_GENERAL_FEEDBACK)
|
| 515 |
+
for choice in question.choices:
|
| 516 |
+
if choice.feedback_raw is not None:
|
| 517 |
+
resprocessing.append(ITEM_RESPROCESSING_MCTF_CHOICE_FEEDBACK.format(ident=f'text2qti_choice_{choice.id}'))
|
| 518 |
+
if question.correct_feedback_raw is not None:
|
| 519 |
+
resprocessing.append(ITEM_RESPROCESSING_MCTF_SET_CORRECT_WITH_FEEDBACK.format(ident=f'text2qti_choice_{correct_choice.id}'))
|
| 520 |
+
else:
|
| 521 |
+
resprocessing.append(ITEM_RESPROCESSING_MCTF_SET_CORRECT_NO_FEEDBACK.format(ident=f'text2qti_choice_{correct_choice.id}'))
|
| 522 |
+
if question.incorrect_feedback_raw is not None:
|
| 523 |
+
resprocessing.append(ITEM_RESPROCESSING_MCTF_INCORRECT_FEEDBACK)
|
| 524 |
+
resprocessing.append(ITEM_RESPROCESSING_END)
|
| 525 |
+
xml.extend(resprocessing)
|
| 526 |
+
elif question.type == 'short_answer_question':
|
| 527 |
+
resprocessing = []
|
| 528 |
+
resprocessing.append(ITEM_RESPROCESSING_START)
|
| 529 |
+
if question.feedback_raw is not None:
|
| 530 |
+
resprocessing.append(ITEM_RESPROCESSING_SHORTANS_GENERAL_FEEDBACK)
|
| 531 |
+
for choice in question.choices:
|
| 532 |
+
if choice.feedback_raw is not None:
|
| 533 |
+
resprocessing.append(ITEM_RESPROCESSING_SHORTANS_CHOICE_FEEDBACK.format(ident=f'text2qti_choice_{choice.id}', answer_xml=choice.choice_xml))
|
| 534 |
+
varequal = []
|
| 535 |
+
for choice in question.choices:
|
| 536 |
+
varequal.append(ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_VAREQUAL.format(answer_xml=choice.choice_xml))
|
| 537 |
+
if question.correct_feedback_raw is not None:
|
| 538 |
+
resprocessing.append(ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_WITH_FEEDBACK.format(varequal='\n'.join(varequal)))
|
| 539 |
+
else:
|
| 540 |
+
resprocessing.append(ITEM_RESPROCESSING_SHORTANS_SET_CORRECT_NO_FEEDBACK.format(varequal='\n'.join(varequal)))
|
| 541 |
+
if question.incorrect_feedback_raw is not None:
|
| 542 |
+
resprocessing.append(ITEM_RESPROCESSING_SHORTANS_INCORRECT_FEEDBACK)
|
| 543 |
+
resprocessing.append(ITEM_RESPROCESSING_END)
|
| 544 |
+
xml.extend(resprocessing)
|
| 545 |
+
elif question.type == 'multiple_answers_question':
|
| 546 |
+
resprocessing = []
|
| 547 |
+
resprocessing.append(ITEM_RESPROCESSING_START)
|
| 548 |
+
if question.feedback_raw is not None:
|
| 549 |
+
resprocessing.append(ITEM_RESPROCESSING_MULTANS_GENERAL_FEEDBACK)
|
| 550 |
+
for choice in question.choices:
|
| 551 |
+
if choice.feedback_raw is not None:
|
| 552 |
+
resprocessing.append(ITEM_RESPROCESSING_MULTANS_CHOICE_FEEDBACK.format(ident=f'text2qti_choice_{choice.id}'))
|
| 553 |
+
varequal = []
|
| 554 |
+
for choice in question.choices:
|
| 555 |
+
if choice.correct:
|
| 556 |
+
varequal.append(ITEM_RESPROCESSING_MULTANS_SET_CORRECT_VAREQUAL_CORRECT.format(ident=f'text2qti_choice_{choice.id}'))
|
| 557 |
+
else:
|
| 558 |
+
varequal.append(ITEM_RESPROCESSING_MULTANS_SET_CORRECT_VAREQUAL_INCORRECT.format(ident=f'text2qti_choice_{choice.id}'))
|
| 559 |
+
if question.correct_feedback_raw is not None:
|
| 560 |
+
resprocessing.append(ITEM_RESPROCESSING_MULTANS_SET_CORRECT_WITH_FEEDBACK.format(varequal='\n'.join(varequal)))
|
| 561 |
+
else:
|
| 562 |
+
resprocessing.append(ITEM_RESPROCESSING_MULTANS_SET_CORRECT_NO_FEEDBACK.format(varequal='\n'.join(varequal)))
|
| 563 |
+
if question.incorrect_feedback_raw is not None:
|
| 564 |
+
resprocessing.append(ITEM_RESPROCESSING_MULTANS_INCORRECT_FEEDBACK)
|
| 565 |
+
resprocessing.append(ITEM_RESPROCESSING_END)
|
| 566 |
+
xml.extend(resprocessing)
|
| 567 |
+
elif question.type == 'numerical_question':
|
| 568 |
+
xml.append(ITEM_RESPROCESSING_START)
|
| 569 |
+
if question.feedback_raw is not None:
|
| 570 |
+
xml.append(ITEM_RESPROCESSING_NUM_GENERAL_FEEDBACK)
|
| 571 |
+
if question.correct_feedback_raw is None:
|
| 572 |
+
if question.numerical_exact is None:
|
| 573 |
+
item_resprocessing_num_set_correct = ITEM_RESPROCESSING_NUM_RANGE_SET_CORRECT_NO_FEEDBACK
|
| 574 |
+
else:
|
| 575 |
+
item_resprocessing_num_set_correct = ITEM_RESPROCESSING_NUM_EXACT_SET_CORRECT_NO_FEEDBACK
|
| 576 |
+
else:
|
| 577 |
+
if question.numerical_exact is None:
|
| 578 |
+
item_resprocessing_num_set_correct = ITEM_RESPROCESSING_NUM_RANGE_SET_CORRECT_WITH_FEEDBACK
|
| 579 |
+
else:
|
| 580 |
+
item_resprocessing_num_set_correct = ITEM_RESPROCESSING_NUM_EXACT_SET_CORRECT_WITH_FEEDBACK
|
| 581 |
+
xml.append(item_resprocessing_num_set_correct.format(num_min=question.numerical_min_html_xml,
|
| 582 |
+
num_exact=question.numerical_exact_html_xml,
|
| 583 |
+
num_max=question.numerical_max_html_xml))
|
| 584 |
+
if question.incorrect_feedback_raw is not None:
|
| 585 |
+
xml.append(ITEM_RESPROCESSING_NUM_INCORRECT_FEEDBACK)
|
| 586 |
+
xml.append(ITEM_RESPROCESSING_END)
|
| 587 |
+
elif question.type == 'essay_question':
|
| 588 |
+
xml.append(ITEM_RESPROCESSING_START)
|
| 589 |
+
xml.append(ITEM_RESPROCESSING_ESSAY)
|
| 590 |
+
if question.feedback_raw is not None:
|
| 591 |
+
xml.append(ITEM_RESPROCESSING_ESSAY_GENERAL_FEEDBACK)
|
| 592 |
+
xml.append(ITEM_RESPROCESSING_END)
|
| 593 |
+
elif question.type == 'file_upload_question':
|
| 594 |
+
xml.append(ITEM_RESPROCESSING_START)
|
| 595 |
+
if question.feedback_raw is not None:
|
| 596 |
+
xml.append(ITEM_RESPROCESSING_UPLOAD_GENERAL_FEEDBACK)
|
| 597 |
+
xml.append(ITEM_RESPROCESSING_END)
|
| 598 |
+
else:
|
| 599 |
+
raise ValueError
|
| 600 |
+
|
| 601 |
+
if question.type in ('true_false_question', 'multiple_choice_question',
|
| 602 |
+
'short_answer_question', 'multiple_answers_question',
|
| 603 |
+
'numerical_question', 'essay_question', 'file_upload_question'):
|
| 604 |
+
if question.feedback_raw is not None:
|
| 605 |
+
xml.append(ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_GENERAL.format(feedback=question.feedback_html_xml))
|
| 606 |
+
if question.correct_feedback_raw is not None:
|
| 607 |
+
xml.append(ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_CORRECT.format(feedback=question.correct_feedback_html_xml))
|
| 608 |
+
if question.incorrect_feedback_raw is not None:
|
| 609 |
+
xml.append(ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_INCORRECT.format(feedback=question.incorrect_feedback_html_xml))
|
| 610 |
+
if question.type in ('true_false_question', 'multiple_choice_question',
|
| 611 |
+
'short_answer_question', 'multiple_answers_question'):
|
| 612 |
+
for choice in question.choices:
|
| 613 |
+
if choice.feedback_raw is not None:
|
| 614 |
+
xml.append(ITEM_FEEDBACK_MCTF_SHORTANS_MULTANS_NUM_INDIVIDUAL.format(ident=f'text2qti_choice_{choice.id}',
|
| 615 |
+
feedback=choice.feedback_html_xml))
|
| 616 |
+
|
| 617 |
+
xml.append(END_ITEM)
|
| 618 |
+
|
| 619 |
+
xml.append(AFTER_ITEMS)
|
| 620 |
+
|
| 621 |
+
return ''.join(xml)
|
text2qti/xml_assessment_meta.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
from typing import Union
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
TEMPLATE = '''\
|
| 15 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 16 |
+
<quiz identifier="{assessment_identifier}" xmlns="http://canvas.instructure.com/xsd/cccv1p0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://canvas.instructure.com/xsd/cccv1p0 https://canvas.instructure.com/xsd/cccv1p0.xsd">
|
| 17 |
+
<title>{title}</title>
|
| 18 |
+
<description>{description}</description>
|
| 19 |
+
<shuffle_answers>{shuffle_answers}</shuffle_answers>
|
| 20 |
+
<scoring_policy>keep_highest</scoring_policy>
|
| 21 |
+
<hide_results>{hide_results}</hide_results>
|
| 22 |
+
<quiz_type>assignment</quiz_type>
|
| 23 |
+
<points_possible>{points_possible:.1f}</points_possible>
|
| 24 |
+
<require_lockdown_browser>false</require_lockdown_browser>
|
| 25 |
+
<require_lockdown_browser_for_results>false</require_lockdown_browser_for_results>
|
| 26 |
+
<require_lockdown_browser_monitor>false</require_lockdown_browser_monitor>
|
| 27 |
+
<lockdown_browser_monitor_data/>
|
| 28 |
+
<show_correct_answers>{show_correct_answers}</show_correct_answers>
|
| 29 |
+
<anonymous_submissions>false</anonymous_submissions>
|
| 30 |
+
<could_be_locked>false</could_be_locked>
|
| 31 |
+
<allowed_attempts>1</allowed_attempts>
|
| 32 |
+
<one_question_at_a_time>{one_question_at_a_time}</one_question_at_a_time>
|
| 33 |
+
<cant_go_back>{cant_go_back}</cant_go_back>
|
| 34 |
+
<available>false</available>
|
| 35 |
+
<one_time_results>false</one_time_results>
|
| 36 |
+
<show_correct_answers_last_attempt>false</show_correct_answers_last_attempt>
|
| 37 |
+
<only_visible_to_overrides>false</only_visible_to_overrides>
|
| 38 |
+
<module_locked>false</module_locked>
|
| 39 |
+
<assignment identifier="{assignment_identifier}">
|
| 40 |
+
<title>{title}</title>
|
| 41 |
+
<due_at/>
|
| 42 |
+
<lock_at/>
|
| 43 |
+
<unlock_at/>
|
| 44 |
+
<module_locked>false</module_locked>
|
| 45 |
+
<workflow_state>unpublished</workflow_state>
|
| 46 |
+
<assignment_overrides>
|
| 47 |
+
</assignment_overrides>
|
| 48 |
+
<quiz_identifierref>{assessment_identifier}</quiz_identifierref>
|
| 49 |
+
<allowed_extensions></allowed_extensions>
|
| 50 |
+
<has_group_category>false</has_group_category>
|
| 51 |
+
<points_possible>{points_possible:.1f}</points_possible>
|
| 52 |
+
<grading_type>points</grading_type>
|
| 53 |
+
<all_day>false</all_day>
|
| 54 |
+
<submission_types>online_quiz</submission_types>
|
| 55 |
+
<position>1</position>
|
| 56 |
+
<turnitin_enabled>false</turnitin_enabled>
|
| 57 |
+
<vericite_enabled>false</vericite_enabled>
|
| 58 |
+
<peer_review_count>0</peer_review_count>
|
| 59 |
+
<peer_reviews>false</peer_reviews>
|
| 60 |
+
<automatic_peer_reviews>false</automatic_peer_reviews>
|
| 61 |
+
<anonymous_peer_reviews>false</anonymous_peer_reviews>
|
| 62 |
+
<grade_group_students_individually>false</grade_group_students_individually>
|
| 63 |
+
<freeze_on_copy>false</freeze_on_copy>
|
| 64 |
+
<omit_from_final_grade>false</omit_from_final_grade>
|
| 65 |
+
<intra_group_peer_reviews>false</intra_group_peer_reviews>
|
| 66 |
+
<only_visible_to_overrides>false</only_visible_to_overrides>
|
| 67 |
+
<post_to_sis>false</post_to_sis>
|
| 68 |
+
<moderated_grading>false</moderated_grading>
|
| 69 |
+
<grader_count>0</grader_count>
|
| 70 |
+
<grader_comments_visible_to_graders>true</grader_comments_visible_to_graders>
|
| 71 |
+
<anonymous_grading>false</anonymous_grading>
|
| 72 |
+
<graders_anonymous_to_graders>false</graders_anonymous_to_graders>
|
| 73 |
+
<grader_names_visible_to_final_grader>true</grader_names_visible_to_final_grader>
|
| 74 |
+
<anonymous_instructor_annotations>false</anonymous_instructor_annotations>
|
| 75 |
+
<post_policy>
|
| 76 |
+
<post_manually>false</post_manually>
|
| 77 |
+
</post_policy>
|
| 78 |
+
</assignment>
|
| 79 |
+
<assignment_group_identifierref>{assignment_group_identifier}</assignment_group_identifierref>
|
| 80 |
+
<assignment_overrides>
|
| 81 |
+
</assignment_overrides>
|
| 82 |
+
</quiz>
|
| 83 |
+
'''
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def assessment_meta(*,
|
| 87 |
+
assessment_identifier: str,
|
| 88 |
+
assignment_group_identifier: str,
|
| 89 |
+
assignment_identifier: str,
|
| 90 |
+
title_xml: str,
|
| 91 |
+
description_html_xml: str,
|
| 92 |
+
points_possible: Union[int, float],
|
| 93 |
+
shuffle_answers: str,
|
| 94 |
+
show_correct_answers: str,
|
| 95 |
+
one_question_at_a_time: str,
|
| 96 |
+
cant_go_back: str) -> str:
|
| 97 |
+
'''
|
| 98 |
+
Generate `assessment_meta.xml`.
|
| 99 |
+
'''
|
| 100 |
+
return TEMPLATE.format(assessment_identifier=assessment_identifier,
|
| 101 |
+
assignment_identifier=assignment_identifier,
|
| 102 |
+
assignment_group_identifier=assignment_group_identifier,
|
| 103 |
+
title=title_xml,
|
| 104 |
+
description=description_html_xml,
|
| 105 |
+
points_possible=points_possible,
|
| 106 |
+
shuffle_answers=shuffle_answers,
|
| 107 |
+
show_correct_answers=show_correct_answers,
|
| 108 |
+
hide_results='always' if show_correct_answers == 'false' else '',
|
| 109 |
+
one_question_at_a_time=one_question_at_a_time,
|
| 110 |
+
cant_go_back=cant_go_back)
|
text2qti/xml_imsmanifest.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
#
|
| 3 |
+
# Copyright (c) 2020, Geoffrey M. Poore
|
| 4 |
+
# All rights reserved.
|
| 5 |
+
#
|
| 6 |
+
# Licensed under the BSD 3-Clause License:
|
| 7 |
+
# http://opensource.org/licenses/BSD-3-Clause
|
| 8 |
+
#
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import datetime
|
| 12 |
+
from typing import Dict, Optional
|
| 13 |
+
from .quiz import Image
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
MANIFEST_START = '''\
|
| 17 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 18 |
+
<manifest identifier="{manifest_identifier}" xmlns="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1" xmlns:lom="http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource" xmlns:imsmd="http://www.imsglobal.org/xsd/imsmd_v1p2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/xsd/imscp_v1p1.xsd http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd http://www.imsglobal.org/xsd/imsmd_v1p2 http://www.imsglobal.org/xsd/imsmd_v1p2p2.xsd">
|
| 19 |
+
<metadata>
|
| 20 |
+
<schema>IMS Content</schema>
|
| 21 |
+
<schemaversion>1.1.3</schemaversion>
|
| 22 |
+
<imsmd:lom>
|
| 23 |
+
<imsmd:general>
|
| 24 |
+
<imsmd:title>
|
| 25 |
+
<imsmd:string>QTI assessment generated by text2qti</imsmd:string>
|
| 26 |
+
</imsmd:title>
|
| 27 |
+
</imsmd:general>
|
| 28 |
+
<imsmd:lifeCycle>
|
| 29 |
+
<imsmd:contribute>
|
| 30 |
+
<imsmd:date>
|
| 31 |
+
<imsmd:dateTime>{date}</imsmd:dateTime>
|
| 32 |
+
</imsmd:date>
|
| 33 |
+
</imsmd:contribute>
|
| 34 |
+
</imsmd:lifeCycle>
|
| 35 |
+
<imsmd:rights>
|
| 36 |
+
<imsmd:copyrightAndOtherRestrictions>
|
| 37 |
+
<imsmd:value>yes</imsmd:value>
|
| 38 |
+
</imsmd:copyrightAndOtherRestrictions>
|
| 39 |
+
<imsmd:description>
|
| 40 |
+
<imsmd:string>Private (Copyrighted) - http://en.wikipedia.org/wiki/Copyright</imsmd:string>
|
| 41 |
+
</imsmd:description>
|
| 42 |
+
</imsmd:rights>
|
| 43 |
+
</imsmd:lom>
|
| 44 |
+
</metadata>
|
| 45 |
+
<organizations/>
|
| 46 |
+
<resources>
|
| 47 |
+
<resource identifier="{assessment_identifier}" type="imsqti_xmlv1p2">
|
| 48 |
+
<file href="{assessment_identifier}/{assessment_identifier}.xml"/>
|
| 49 |
+
<dependency identifierref="{dependency_identifier}"/>
|
| 50 |
+
</resource>
|
| 51 |
+
<resource identifier="{dependency_identifier}" type="associatedcontent/imscc_xmlv1p1/learning-application-resource" href="{assessment_identifier}/assessment_meta.xml">
|
| 52 |
+
<file href="{assessment_identifier}/assessment_meta.xml"/>
|
| 53 |
+
</resource>
|
| 54 |
+
'''
|
| 55 |
+
|
| 56 |
+
IMAGE = '''\
|
| 57 |
+
<resource identifier="text2qti_image_{ident}" type="webcontent" href="{path}">
|
| 58 |
+
<file href="{path}"/>
|
| 59 |
+
</resource>
|
| 60 |
+
'''
|
| 61 |
+
|
| 62 |
+
MANIFEST_END = '''\
|
| 63 |
+
</resources>
|
| 64 |
+
</manifest>
|
| 65 |
+
'''
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def imsmanifest(*,
|
| 69 |
+
manifest_identifier: str,
|
| 70 |
+
assessment_identifier: str,
|
| 71 |
+
dependency_identifier: str,
|
| 72 |
+
images: Dict[str, Image],
|
| 73 |
+
date: Optional[str]=None) -> str:
|
| 74 |
+
'''
|
| 75 |
+
Generate `imsmanifest.xml`.
|
| 76 |
+
'''
|
| 77 |
+
if date is None:
|
| 78 |
+
date = str(datetime.date.today())
|
| 79 |
+
xml = []
|
| 80 |
+
xml.append(MANIFEST_START.format(manifest_identifier=manifest_identifier,
|
| 81 |
+
assessment_identifier=assessment_identifier,
|
| 82 |
+
dependency_identifier=dependency_identifier,
|
| 83 |
+
date=date))
|
| 84 |
+
for image in images.values():
|
| 85 |
+
xml.append(IMAGE.format(ident=image.id, path=image.qti_xml_path))
|
| 86 |
+
xml.append(MANIFEST_END)
|
| 87 |
+
return ''.join(xml)
|