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()
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.
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 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).
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).
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.
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.
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.