quizcomp.converter.template

The most common way to render Quiz Composer quizzes is via templates. We use the Jinja template system.

This comment describes the general information passed in the Jinja context for each object. See the source code for full information.

The standard rendering context sent to most templates will always include:

  • this: typing.Any -- The context object being rendered (e.g., a quiz, group, or question).
  • quiz: quizcomp.model.quiz.Quiz -- The current quiz (often a variant) being rendered.
  • answer_key: bool -- Whether this conversion if for an answer key.

Core types (quiz, group, question) will additionally include:

  • id: str -- An identifier specific to this.
  • children_content: str -- The rendered content from this' children (if any).

Questions will also have:

  • custom_header: typing.Union[str, None] -- An optional custom header to use for this question.
  • number: int -- The number that should be displayed for this object (if any), e.g., a question's number.
  1"""
  2The most common way to render Quiz Composer quizzes is via templates.
  3We use the [Jinja](https://jinja.palletsprojects.com) template system.
  4
  5This comment describes the general information passed in the Jinja context for each object.
  6See the source code for full information.
  7
  8The standard rendering context sent to most templates will always include:
  9 - `this: typing.Any` -- The context object being rendered (e.g., a quiz, group, or question).
 10 - `quiz: quizcomp.model.quiz.Quiz` -- The current quiz (often a variant) being rendered.
 11 - `answer_key: bool` -- Whether this conversion if for an answer key.
 12
 13Core types (quiz, group, question) will additionally include:
 14 - `id: str` -- An identifier specific to this.
 15 - `children_content: str` -- The rendered content from this' children (if any).
 16
 17 Questions will also have:
 18 - `custom_header: typing.Union[str, None]` -- An optional custom header to use for this question.
 19 - `number: int` -- The number that should be displayed for this object (if any), e.g., a question's number.
 20"""
 21
 22import logging
 23import os
 24import re
 25import typing
 26import urllib.parse
 27
 28import edq.net.request
 29import edq.util.dirent
 30import jinja2
 31
 32import quizcomp.converter.converter
 33import quizcomp.model.base
 34import quizcomp.model.config
 35import quizcomp.model.constants
 36import quizcomp.model.errors
 37import quizcomp.model.group
 38import quizcomp.model.question
 39import quizcomp.model.quiz
 40import quizcomp.parser.document
 41
 42_logger = logging.getLogger(__name__)
 43
 44TEMPLATE_FILENAME_QUIZ: str = 'quiz.template'
 45TEMPLATE_FILENAME_QUESTION_SEPARATOR: str = 'question-separator.template'
 46TEMPLATE_FILENAME_GROUP: str = 'group.template'
 47TEMPLATE_FILENAME_GROUP_SEPARATOR: str = 'group-separator.template'
 48
 49DEFAULT_JINJA_OPTIONS: typing.Dict[str, typing.Any] = {
 50    'trim_blocks': True,
 51    'lstrip_blocks': True,
 52    'autoescape': jinja2.select_autoescape(),
 53}
 54
 55class TemplateConverter(quizcomp.converter.converter.Converter):
 56    """
 57    The base class for a converter that uses templates.
 58    """
 59
 60    def __init__(self,
 61            format: quizcomp.model.constants.Format,
 62            template_dir: str,
 63            jinja_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 64            jinja_filters: typing.Union[typing.Dict[str, typing.Any], None] = None,
 65            jinja_globals: typing.Union[typing.Dict[str, typing.Any], None] = None,
 66            image_base_dir: typing.Union[str, None] = None,
 67            **kwargs: typing.Any) -> None:
 68        super().__init__(**kwargs)
 69
 70        if (not os.path.isdir(template_dir)):
 71            raise ValueError(f"Provided template dir ('{template_dir}') does not exist or is not a dir.")
 72
 73        self.format: quizcomp.model.constants.Format = format
 74        """ The format being converted to. """
 75
 76        self.template_dir: str = template_dir
 77        """ The directory containing the templates to use. """
 78
 79        self.image_base_dir: typing.Union[str, None] = image_base_dir
 80        """
 81        The location images are to be stored.
 82        Some converters will need to store image paths.
 83
 84        If set to None, then no images will be stored (even if their source strings get rewritten).
 85        """
 86
 87        if (jinja_options is None):
 88            jinja_options = {}
 89
 90        self.jinja_options: typing.Dict[str, typing.Any] = DEFAULT_JINJA_OPTIONS.copy()
 91        """ Top-level options to pass Jinja. """
 92
 93        self.jinja_options.update(jinja_options)
 94
 95        self.env: jinja2.Environment = jinja2.Environment(
 96            loader = jinja2.FileSystemLoader(self.template_dir, followlinks = True),
 97            **self.jinja_options,
 98        )
 99        """ The Jinja environment for this converter. """
100
101        if (jinja_globals is None):
102            jinja_globals = {}
103
104        self.env.globals.update(jinja_globals)
105
106        if (jinja_filters is None):
107            jinja_filters = {}
108
109        for (name, function) in jinja_filters.items():
110            self.env.filters[name] = function
111
112    def convert_quiz(self, quiz: quizcomp.model.quiz.Quiz, **kwargs: typing.Any) -> str:
113        """ Convert an entire quiz (including variants). """
114
115        return self._convert_quiz(quiz)
116
117    def convert_variant(self, variant: quizcomp.model.quiz.Variant, **kwargs: typing.Any) -> str:
118        """ Convert a standard quiz variant. """
119
120        return self._convert_quiz(variant)
121
122    def prepare(self, quiz: quizcomp.model.quiz.Quiz) -> None:
123        """ A first chance for children to prepare for converting the given quiz. """
124
125    def finalize(self, quiz: quizcomp.model.quiz.Quiz, text: str) -> str:
126        """ A final chance for children to modify the output. """
127
128        return text
129
130    def _convert_quiz(self, quiz: quizcomp.model.quiz.Quiz) -> str:
131        """ Convert a quiz (or variant). """
132
133        self.prepare(quiz)
134
135        quiz_id = '0'
136        children_content, _ = self._convert_children(quiz, quiz, quiz_id, self._convert_group, self._convert_group_separator, 1)
137
138        context = {
139            'this': quiz,
140            'quiz': quiz,
141            'answer_key': self.answer_key,
142            'id': quiz_id,
143            'children_content': children_content,
144        }
145
146        template = self.env.get_template(TEMPLATE_FILENAME_QUIZ)
147        output = template.render(**context)
148
149        return self.finalize(quiz, output)
150
151    def _convert_children(self,
152            quiz: quizcomp.model.quiz.Quiz,
153            parent: quizcomp.model.base.CoreType,
154            parent_id: str,
155            convert_child_func: typing.Callable,
156            convert_child_separator_func: typing.Callable,
157            running_question_number: int,
158            ) -> typing.Tuple[str, int]:
159        """ Convert a list of children. """
160
161        last_child = None
162        last_child_id = None
163        last_child_index = None
164
165        children_content = []
166        for (child_index, child) in enumerate(parent.children):
167            child_id = f"{parent_id}.{child_index}"
168
169            # Add in a separator if we are between two children.
170            if (last_child is not None):
171                separator_content = convert_child_separator_func(
172                    quiz, parent,
173                    last_child, last_child_id, last_child_index,
174                    child, child_id, child_index,
175                )
176                children_content.append(separator_content)
177
178            child_content, running_question_number = convert_child_func(quiz, child, child_id, child_index, running_question_number)
179            children_content.append(child_content)
180
181            last_child = child
182            last_child_id = child_id
183            last_child_index = child_index
184
185        return "\n".join(children_content), running_question_number
186
187    def _convert_group(self,
188            quiz: quizcomp.model.quiz.Quiz,
189            group: quizcomp.model.group.Group, group_id: str, child_index: typing.Union[int, None],
190            running_question_number: int,
191            ) -> typing.Tuple[str, int]:
192        """ Convert a group. """
193
194        children_content, running_question_number = self._convert_children(
195            quiz,
196            group, group_id,
197            self._convert_question, self._convert_question_separator,
198            running_question_number,
199        )
200
201        context = {
202            'this': group,
203            'quiz': quiz,
204            'answer_key': self.answer_key,
205            'id': group_id,
206            'child_index': child_index,
207            'children_content': children_content,
208        }
209
210        template = self.env.get_template(TEMPLATE_FILENAME_GROUP)
211        return template.render(**context), running_question_number
212
213    def _convert_group_separator(self,
214            quiz: quizcomp.model.quiz.Quiz,
215            parent: quizcomp.model.base.CoreType,
216            previous: quizcomp.model.base.CoreType, previous_id: str, previous_number: typing.Union[int, None],
217            next: quizcomp.model.base.CoreType, next_id: str, next_number: typing.Union[int, None],
218            ) -> str:
219        """ Create a group separator. """
220
221        return self._convert_separator(
222            TEMPLATE_FILENAME_GROUP_SEPARATOR,
223            quiz, parent,
224            previous, previous_id, previous_number,
225            next, next_id, next_number,
226        )
227
228    def _convert_question(self,
229            quiz: quizcomp.model.quiz.Quiz,
230            question: quizcomp.model.question.Question, question_id: str, child_index: typing.Union[int, None],
231            running_question_number: int,
232            ) -> typing.Tuple[str, int]:
233        """ Convert a question. """
234
235        question_number = None
236        if (question.get_config(quizcomp.model.config.OPTION_SKIP_NUMBERING_KEY) is not True):
237            question_number = running_question_number
238            running_question_number += 1
239
240        custom_header: typing.Union[quizcomp.parser.document.ParsedDocument, None] = None
241
242        raw_custom_header = question.get_config(quizcomp.model.config.OPTION_CUSTOM_HEADER)
243        if (raw_custom_header is not None):
244            quizcomp.model.errors.check_type(raw_custom_header, str, "'custom_header'", context = quiz)
245            text = typing.cast(str, raw_custom_header)
246            custom_header = quizcomp.parser.document.ParsedDocument.parse_text(text)
247
248        context = {
249            'this': question,
250            'quiz': quiz,
251            'answer_key': self.answer_key,
252            'id': question_id,
253            'child_index': child_index,
254            'number': question_number,
255            'children_content': None,
256            'custom_header': custom_header,
257        }
258
259        template = self.env.get_template(f"questions/{question.question_type.value}.template")
260        return template.render(**context), running_question_number
261
262    def _convert_question_separator(self,
263            quiz: quizcomp.model.quiz.Quiz,
264            parent: quizcomp.model.base.CoreType,
265            previous: quizcomp.model.base.CoreType, previous_id: str, previous_number: typing.Union[int, None],
266            next: quizcomp.model.base.CoreType, next_id: str, next_number: typing.Union[int, None],
267            ) -> str:
268        """ Create a question separator. """
269
270        return self._convert_separator(
271            TEMPLATE_FILENAME_QUESTION_SEPARATOR,
272            quiz, parent,
273            previous, previous_id, previous_number,
274            next, next_id, next_number,
275        )
276
277    def _convert_separator(self,
278            template_name: str,
279            quiz: quizcomp.model.quiz.Quiz,
280            parent: quizcomp.model.base.CoreType,
281            previous: quizcomp.model.base.CoreType, previous_id: str, previous_number: typing.Union[int, None],
282            next: quizcomp.model.base.CoreType, next_id: str, next_number: typing.Union[int, None],
283            ) -> str:
284        """ Create a separator. """
285
286        context = {
287            'this': parent,
288            'quiz': quiz,
289            'answer_key': self.answer_key,
290            'previous': previous,
291            'previous_id': previous_id,
292            'previous_number': previous_number,
293            'next': next,
294            'next_id': next_id,
295            'next_number': next_number,
296        }
297
298        template = self.env.get_template(template_name)
299        text = template.render(**context)
300
301        return text
302
303    def _store_images(self, quiz: quizcomp.model.quiz.Quiz) -> None:
304        """
305        Prepare images for conversions.
306        Replace the source in image tokens.
307        Write images to a common directory.
308        """
309
310        seen_sources: typing.Dict[str, str] = {}
311
312        for document in quiz.collect_all_documents():
313            modified_tokens = False
314            for image_token in document.collect_images():
315                original_source = image_token.attrGet('original_src')
316                if (original_source is None):
317                    original_source = image_token.attrGet('src')
318
319                if ((original_source is None) or len(str(original_source)) == 0):
320                    _logger.warning("Could not locate image source for '%s'.", image_token.content)
321
322                original_source = str(original_source)
323
324                if (original_source in seen_sources):
325                    new_source = seen_sources[original_source]
326                else:
327                    new_source = self._handle_image(quiz, original_source, document.context.base_dir)
328                    seen_sources[original_source] = new_source
329
330                image_token.attrSet('original_src', original_source)
331                image_token.attrSet('src', new_source)
332                modified_tokens = True
333
334            if (modified_tokens):
335                document.tokens_updated()
336
337    def _handle_image(self, quiz: quizcomp.model.quiz.Quiz, source: str, document_base_dir: str) -> str:
338        """
339        Handle an image that will be stored and return the new source for the image.
340
341        By default, "handling" and image entails:
342         - fetching an image (if it is not available locally),
343         - copying it to the image directory (if an image directory exists),
344         - and returning the new relative source path for the image.
345        """
346
347        is_http = re.match(r'^http(s)?://', source)
348        if (is_http):
349            url_path = urllib.parse.urlsplit(source).path
350            filename = url_path.split('/')[-1]
351        else:
352            filename = os.path.basename(source)
353
354        # If there is no image base dir to store images into, then just return with the newly formed source.
355        if (self.image_base_dir is None):
356            return self._form_image_source(filename, quiz)
357
358        (basename, ext) = os.path.splitext(filename)
359        path = os.path.join(self.image_base_dir, filename)
360
361        count = 0
362        while (os.path.exists(path)):
363            filename = f"{basename}_{count:03d}{ext}"
364            path = os.path.join(self.image_base_dir, filename)
365            count += 1
366
367            if (count >= quizcomp.model.constants.MAX_IMAGE_RENAMES):
368                raise quizcomp.model.errors.QuizValidationError(f"Cannot create unique filename for image: '{source}'.", context = quiz)
369
370        new_source = self._form_image_source(filename, quiz)
371
372        if (is_http):
373            response, _ = edq.net.request.make_get(source)
374            edq.util.dirent.write_file_bytes(path, response.content)
375        else:
376            if (not os.path.isabs(source)):
377                source = os.path.join(document_base_dir, source)
378
379            source = os.path.abspath(source)
380            edq.util.dirent.copy(source, path)
381
382        return new_source
383
384    def _form_image_source(self, filename: str, quiz: quizcomp.model.quiz.Quiz) -> str:
385        """ Create the image source string that will go inside stored image tokens using the base filename. """
386
387        return os.path.join('images', filename)
388
389    def _restore_image_sources(self, quiz: quizcomp.model.quiz.Quiz) -> None:
390        """ Replace any modified image sources with their original source. """
391
392        for document in quiz.collect_all_documents():
393            modified_tokens = False
394            for image_token in document.collect_images():
395                original_source = image_token.attrGet('original_src')
396                if (original_source is None):
397                    continue
398
399                image_token.attrSet('src', str(original_source))
400                image_token.attrs.pop('original_src', None)
401
402            if (modified_tokens):
403                document.tokens_updated()
TEMPLATE_FILENAME_QUIZ: str = 'quiz.template'
TEMPLATE_FILENAME_QUESTION_SEPARATOR: str = 'question-separator.template'
TEMPLATE_FILENAME_GROUP: str = 'group.template'
TEMPLATE_FILENAME_GROUP_SEPARATOR: str = 'group-separator.template'
DEFAULT_JINJA_OPTIONS: Dict[str, Any] = {'trim_blocks': True, 'lstrip_blocks': True, 'autoescape': <function select_autoescape.<locals>.autoescape>}
class TemplateConverter(quizcomp.converter.converter.Converter):
 56class TemplateConverter(quizcomp.converter.converter.Converter):
 57    """
 58    The base class for a converter that uses templates.
 59    """
 60
 61    def __init__(self,
 62            format: quizcomp.model.constants.Format,
 63            template_dir: str,
 64            jinja_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 65            jinja_filters: typing.Union[typing.Dict[str, typing.Any], None] = None,
 66            jinja_globals: typing.Union[typing.Dict[str, typing.Any], None] = None,
 67            image_base_dir: typing.Union[str, None] = None,
 68            **kwargs: typing.Any) -> None:
 69        super().__init__(**kwargs)
 70
 71        if (not os.path.isdir(template_dir)):
 72            raise ValueError(f"Provided template dir ('{template_dir}') does not exist or is not a dir.")
 73
 74        self.format: quizcomp.model.constants.Format = format
 75        """ The format being converted to. """
 76
 77        self.template_dir: str = template_dir
 78        """ The directory containing the templates to use. """
 79
 80        self.image_base_dir: typing.Union[str, None] = image_base_dir
 81        """
 82        The location images are to be stored.
 83        Some converters will need to store image paths.
 84
 85        If set to None, then no images will be stored (even if their source strings get rewritten).
 86        """
 87
 88        if (jinja_options is None):
 89            jinja_options = {}
 90
 91        self.jinja_options: typing.Dict[str, typing.Any] = DEFAULT_JINJA_OPTIONS.copy()
 92        """ Top-level options to pass Jinja. """
 93
 94        self.jinja_options.update(jinja_options)
 95
 96        self.env: jinja2.Environment = jinja2.Environment(
 97            loader = jinja2.FileSystemLoader(self.template_dir, followlinks = True),
 98            **self.jinja_options,
 99        )
100        """ The Jinja environment for this converter. """
101
102        if (jinja_globals is None):
103            jinja_globals = {}
104
105        self.env.globals.update(jinja_globals)
106
107        if (jinja_filters is None):
108            jinja_filters = {}
109
110        for (name, function) in jinja_filters.items():
111            self.env.filters[name] = function
112
113    def convert_quiz(self, quiz: quizcomp.model.quiz.Quiz, **kwargs: typing.Any) -> str:
114        """ Convert an entire quiz (including variants). """
115
116        return self._convert_quiz(quiz)
117
118    def convert_variant(self, variant: quizcomp.model.quiz.Variant, **kwargs: typing.Any) -> str:
119        """ Convert a standard quiz variant. """
120
121        return self._convert_quiz(variant)
122
123    def prepare(self, quiz: quizcomp.model.quiz.Quiz) -> None:
124        """ A first chance for children to prepare for converting the given quiz. """
125
126    def finalize(self, quiz: quizcomp.model.quiz.Quiz, text: str) -> str:
127        """ A final chance for children to modify the output. """
128
129        return text
130
131    def _convert_quiz(self, quiz: quizcomp.model.quiz.Quiz) -> str:
132        """ Convert a quiz (or variant). """
133
134        self.prepare(quiz)
135
136        quiz_id = '0'
137        children_content, _ = self._convert_children(quiz, quiz, quiz_id, self._convert_group, self._convert_group_separator, 1)
138
139        context = {
140            'this': quiz,
141            'quiz': quiz,
142            'answer_key': self.answer_key,
143            'id': quiz_id,
144            'children_content': children_content,
145        }
146
147        template = self.env.get_template(TEMPLATE_FILENAME_QUIZ)
148        output = template.render(**context)
149
150        return self.finalize(quiz, output)
151
152    def _convert_children(self,
153            quiz: quizcomp.model.quiz.Quiz,
154            parent: quizcomp.model.base.CoreType,
155            parent_id: str,
156            convert_child_func: typing.Callable,
157            convert_child_separator_func: typing.Callable,
158            running_question_number: int,
159            ) -> typing.Tuple[str, int]:
160        """ Convert a list of children. """
161
162        last_child = None
163        last_child_id = None
164        last_child_index = None
165
166        children_content = []
167        for (child_index, child) in enumerate(parent.children):
168            child_id = f"{parent_id}.{child_index}"
169
170            # Add in a separator if we are between two children.
171            if (last_child is not None):
172                separator_content = convert_child_separator_func(
173                    quiz, parent,
174                    last_child, last_child_id, last_child_index,
175                    child, child_id, child_index,
176                )
177                children_content.append(separator_content)
178
179            child_content, running_question_number = convert_child_func(quiz, child, child_id, child_index, running_question_number)
180            children_content.append(child_content)
181
182            last_child = child
183            last_child_id = child_id
184            last_child_index = child_index
185
186        return "\n".join(children_content), running_question_number
187
188    def _convert_group(self,
189            quiz: quizcomp.model.quiz.Quiz,
190            group: quizcomp.model.group.Group, group_id: str, child_index: typing.Union[int, None],
191            running_question_number: int,
192            ) -> typing.Tuple[str, int]:
193        """ Convert a group. """
194
195        children_content, running_question_number = self._convert_children(
196            quiz,
197            group, group_id,
198            self._convert_question, self._convert_question_separator,
199            running_question_number,
200        )
201
202        context = {
203            'this': group,
204            'quiz': quiz,
205            'answer_key': self.answer_key,
206            'id': group_id,
207            'child_index': child_index,
208            'children_content': children_content,
209        }
210
211        template = self.env.get_template(TEMPLATE_FILENAME_GROUP)
212        return template.render(**context), running_question_number
213
214    def _convert_group_separator(self,
215            quiz: quizcomp.model.quiz.Quiz,
216            parent: quizcomp.model.base.CoreType,
217            previous: quizcomp.model.base.CoreType, previous_id: str, previous_number: typing.Union[int, None],
218            next: quizcomp.model.base.CoreType, next_id: str, next_number: typing.Union[int, None],
219            ) -> str:
220        """ Create a group separator. """
221
222        return self._convert_separator(
223            TEMPLATE_FILENAME_GROUP_SEPARATOR,
224            quiz, parent,
225            previous, previous_id, previous_number,
226            next, next_id, next_number,
227        )
228
229    def _convert_question(self,
230            quiz: quizcomp.model.quiz.Quiz,
231            question: quizcomp.model.question.Question, question_id: str, child_index: typing.Union[int, None],
232            running_question_number: int,
233            ) -> typing.Tuple[str, int]:
234        """ Convert a question. """
235
236        question_number = None
237        if (question.get_config(quizcomp.model.config.OPTION_SKIP_NUMBERING_KEY) is not True):
238            question_number = running_question_number
239            running_question_number += 1
240
241        custom_header: typing.Union[quizcomp.parser.document.ParsedDocument, None] = None
242
243        raw_custom_header = question.get_config(quizcomp.model.config.OPTION_CUSTOM_HEADER)
244        if (raw_custom_header is not None):
245            quizcomp.model.errors.check_type(raw_custom_header, str, "'custom_header'", context = quiz)
246            text = typing.cast(str, raw_custom_header)
247            custom_header = quizcomp.parser.document.ParsedDocument.parse_text(text)
248
249        context = {
250            'this': question,
251            'quiz': quiz,
252            'answer_key': self.answer_key,
253            'id': question_id,
254            'child_index': child_index,
255            'number': question_number,
256            'children_content': None,
257            'custom_header': custom_header,
258        }
259
260        template = self.env.get_template(f"questions/{question.question_type.value}.template")
261        return template.render(**context), running_question_number
262
263    def _convert_question_separator(self,
264            quiz: quizcomp.model.quiz.Quiz,
265            parent: quizcomp.model.base.CoreType,
266            previous: quizcomp.model.base.CoreType, previous_id: str, previous_number: typing.Union[int, None],
267            next: quizcomp.model.base.CoreType, next_id: str, next_number: typing.Union[int, None],
268            ) -> str:
269        """ Create a question separator. """
270
271        return self._convert_separator(
272            TEMPLATE_FILENAME_QUESTION_SEPARATOR,
273            quiz, parent,
274            previous, previous_id, previous_number,
275            next, next_id, next_number,
276        )
277
278    def _convert_separator(self,
279            template_name: str,
280            quiz: quizcomp.model.quiz.Quiz,
281            parent: quizcomp.model.base.CoreType,
282            previous: quizcomp.model.base.CoreType, previous_id: str, previous_number: typing.Union[int, None],
283            next: quizcomp.model.base.CoreType, next_id: str, next_number: typing.Union[int, None],
284            ) -> str:
285        """ Create a separator. """
286
287        context = {
288            'this': parent,
289            'quiz': quiz,
290            'answer_key': self.answer_key,
291            'previous': previous,
292            'previous_id': previous_id,
293            'previous_number': previous_number,
294            'next': next,
295            'next_id': next_id,
296            'next_number': next_number,
297        }
298
299        template = self.env.get_template(template_name)
300        text = template.render(**context)
301
302        return text
303
304    def _store_images(self, quiz: quizcomp.model.quiz.Quiz) -> None:
305        """
306        Prepare images for conversions.
307        Replace the source in image tokens.
308        Write images to a common directory.
309        """
310
311        seen_sources: typing.Dict[str, str] = {}
312
313        for document in quiz.collect_all_documents():
314            modified_tokens = False
315            for image_token in document.collect_images():
316                original_source = image_token.attrGet('original_src')
317                if (original_source is None):
318                    original_source = image_token.attrGet('src')
319
320                if ((original_source is None) or len(str(original_source)) == 0):
321                    _logger.warning("Could not locate image source for '%s'.", image_token.content)
322
323                original_source = str(original_source)
324
325                if (original_source in seen_sources):
326                    new_source = seen_sources[original_source]
327                else:
328                    new_source = self._handle_image(quiz, original_source, document.context.base_dir)
329                    seen_sources[original_source] = new_source
330
331                image_token.attrSet('original_src', original_source)
332                image_token.attrSet('src', new_source)
333                modified_tokens = True
334
335            if (modified_tokens):
336                document.tokens_updated()
337
338    def _handle_image(self, quiz: quizcomp.model.quiz.Quiz, source: str, document_base_dir: str) -> str:
339        """
340        Handle an image that will be stored and return the new source for the image.
341
342        By default, "handling" and image entails:
343         - fetching an image (if it is not available locally),
344         - copying it to the image directory (if an image directory exists),
345         - and returning the new relative source path for the image.
346        """
347
348        is_http = re.match(r'^http(s)?://', source)
349        if (is_http):
350            url_path = urllib.parse.urlsplit(source).path
351            filename = url_path.split('/')[-1]
352        else:
353            filename = os.path.basename(source)
354
355        # If there is no image base dir to store images into, then just return with the newly formed source.
356        if (self.image_base_dir is None):
357            return self._form_image_source(filename, quiz)
358
359        (basename, ext) = os.path.splitext(filename)
360        path = os.path.join(self.image_base_dir, filename)
361
362        count = 0
363        while (os.path.exists(path)):
364            filename = f"{basename}_{count:03d}{ext}"
365            path = os.path.join(self.image_base_dir, filename)
366            count += 1
367
368            if (count >= quizcomp.model.constants.MAX_IMAGE_RENAMES):
369                raise quizcomp.model.errors.QuizValidationError(f"Cannot create unique filename for image: '{source}'.", context = quiz)
370
371        new_source = self._form_image_source(filename, quiz)
372
373        if (is_http):
374            response, _ = edq.net.request.make_get(source)
375            edq.util.dirent.write_file_bytes(path, response.content)
376        else:
377            if (not os.path.isabs(source)):
378                source = os.path.join(document_base_dir, source)
379
380            source = os.path.abspath(source)
381            edq.util.dirent.copy(source, path)
382
383        return new_source
384
385    def _form_image_source(self, filename: str, quiz: quizcomp.model.quiz.Quiz) -> str:
386        """ Create the image source string that will go inside stored image tokens using the base filename. """
387
388        return os.path.join('images', filename)
389
390    def _restore_image_sources(self, quiz: quizcomp.model.quiz.Quiz) -> None:
391        """ Replace any modified image sources with their original source. """
392
393        for document in quiz.collect_all_documents():
394            modified_tokens = False
395            for image_token in document.collect_images():
396                original_source = image_token.attrGet('original_src')
397                if (original_source is None):
398                    continue
399
400                image_token.attrSet('src', str(original_source))
401                image_token.attrs.pop('original_src', None)
402
403            if (modified_tokens):
404                document.tokens_updated()

The base class for a converter that uses templates.

TemplateConverter( format: quizcomp.model.constants.Format, template_dir: str, jinja_options: Optional[Dict[str, Any]] = None, jinja_filters: Optional[Dict[str, Any]] = None, jinja_globals: Optional[Dict[str, Any]] = None, image_base_dir: Optional[str] = None, **kwargs: Any)
 61    def __init__(self,
 62            format: quizcomp.model.constants.Format,
 63            template_dir: str,
 64            jinja_options: typing.Union[typing.Dict[str, typing.Any], None] = None,
 65            jinja_filters: typing.Union[typing.Dict[str, typing.Any], None] = None,
 66            jinja_globals: typing.Union[typing.Dict[str, typing.Any], None] = None,
 67            image_base_dir: typing.Union[str, None] = None,
 68            **kwargs: typing.Any) -> None:
 69        super().__init__(**kwargs)
 70
 71        if (not os.path.isdir(template_dir)):
 72            raise ValueError(f"Provided template dir ('{template_dir}') does not exist or is not a dir.")
 73
 74        self.format: quizcomp.model.constants.Format = format
 75        """ The format being converted to. """
 76
 77        self.template_dir: str = template_dir
 78        """ The directory containing the templates to use. """
 79
 80        self.image_base_dir: typing.Union[str, None] = image_base_dir
 81        """
 82        The location images are to be stored.
 83        Some converters will need to store image paths.
 84
 85        If set to None, then no images will be stored (even if their source strings get rewritten).
 86        """
 87
 88        if (jinja_options is None):
 89            jinja_options = {}
 90
 91        self.jinja_options: typing.Dict[str, typing.Any] = DEFAULT_JINJA_OPTIONS.copy()
 92        """ Top-level options to pass Jinja. """
 93
 94        self.jinja_options.update(jinja_options)
 95
 96        self.env: jinja2.Environment = jinja2.Environment(
 97            loader = jinja2.FileSystemLoader(self.template_dir, followlinks = True),
 98            **self.jinja_options,
 99        )
100        """ The Jinja environment for this converter. """
101
102        if (jinja_globals is None):
103            jinja_globals = {}
104
105        self.env.globals.update(jinja_globals)
106
107        if (jinja_filters is None):
108            jinja_filters = {}
109
110        for (name, function) in jinja_filters.items():
111            self.env.filters[name] = function

The format being converted to.

template_dir: str

The directory containing the templates to use.

image_base_dir: Optional[str]

The location images are to be stored. Some converters will need to store image paths.

If set to None, then no images will be stored (even if their source strings get rewritten).

jinja_options: Dict[str, Any]

Top-level options to pass Jinja.

env: jinja2.environment.Environment

The Jinja environment for this converter.

def convert_quiz(self, quiz: quizcomp.model.quiz.Quiz, **kwargs: Any) -> str:
113    def convert_quiz(self, quiz: quizcomp.model.quiz.Quiz, **kwargs: typing.Any) -> str:
114        """ Convert an entire quiz (including variants). """
115
116        return self._convert_quiz(quiz)

Convert an entire quiz (including variants).

def convert_variant(self, variant: quizcomp.model.quiz.Variant, **kwargs: Any) -> str:
118    def convert_variant(self, variant: quizcomp.model.quiz.Variant, **kwargs: typing.Any) -> str:
119        """ Convert a standard quiz variant. """
120
121        return self._convert_quiz(variant)

Convert a standard quiz variant.

def prepare(self, quiz: quizcomp.model.quiz.Quiz) -> None:
123    def prepare(self, quiz: quizcomp.model.quiz.Quiz) -> None:
124        """ A first chance for children to prepare for converting the given quiz. """

A first chance for children to prepare for converting the given quiz.

def finalize(self, quiz: quizcomp.model.quiz.Quiz, text: str) -> str:
126    def finalize(self, quiz: quizcomp.model.quiz.Quiz, text: str) -> str:
127        """ A final chance for children to modify the output. """
128
129        return text

A final chance for children to modify the output.