Source code for mathmaker.lib.document.frames.exercise

# -*- coding: utf-8 -*-

# Mathmaker creates automatically maths exercises sheets
# with their answers
# Copyright 2006-2017 Nicolas Hainaux <nh.techn@gmail.com>

# This file is part of Mathmaker.

# Mathmaker is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# any later version.

# Mathmaker is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with Mathmaker; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

import copy
import random
import warnings
from collections import namedtuple
from string import ascii_lowercase as alphabet

from intspan import intspan
from intspan.core import ParseError
from mathmakerlib import required
from mathmakerlib.calculus import is_integer

from mathmaker.lib import shared
from mathmaker import settings
from mathmaker.lib.constants.latex import COLORED_QUESTION_MARK, COLORED_ANSWER
from mathmaker.lib.tools.maths import coprimes_to
from mathmaker.lib.tools.frameworks import read_layout, build_questions_list
from mathmaker.lib.tools.frameworks import get_q_modifier, parse_qid
from .question import Question
from mathmaker.lib.constants import BOOLEAN, SLIDE_CONTENT_SEP
from mathmaker.lib.constants.content \
    import SUBKINDS_TO_UNPACK, UNPACKABLE_SUBKINDS, SOURCES_TO_UNPACK

AVAILABLE_PRESETS = ['default', 'mental calculation']

AVAILABLE_DETAILS_LEVELS = ['maximum', 'medium', 'none']

AVAILABLE_LAYOUT_VARIANTS = ['default', 'tabular', 'slideshow']
DEFAULT_LAYOUT = {'exc': [None, 'all'], 'ans': [None, 'all']}

to_unpack = copy.deepcopy(SUBKINDS_TO_UNPACK)
# In Q_Info below, id is actually kind_subkind
Q_info = namedtuple('Q_info', 'id,kind,subkind,nb_source,options,order')


[docs]def get_common_nb_from_pairs_pair(pair): """ Return the common number found in a pair of pairs. :param pair: the pair of pairs :type pair: a tuple or a list (of two elements) :rtype: number """ if pair[0][0] in pair[1]: return pair[0][0] elif pair[0][1] in pair[1]: return pair[0][1] else: raise ValueError('One of the numbers of the first pair is expected ' 'to be found in the second pair, but hasn\'nt been.')
[docs]def merge_pair_to_tuple(n_tuple, pair, common_nb): """ Add one number from the pair to the n_tuple. It is assumed n_tuple and pair both contain common_nb. If n_tuple has more than 2 elements, it is also assumed that the first one is common_nb. If it has 2 elements, then it might need to get reordered. :param n_tuple: a tuple of 2 or more elements :type n_tuple: tuple :param pair: a tuple of 2 elements. One of them (at least) is common_nb, and will be removed, the other one will be added to n_tuple :type pair: tuple :param common_nb: the number contained in both n_tuple and pair :type common_nb: a number :rtype: tuple """ if not ((common_nb in n_tuple) and (common_nb in pair)): raise ValueError('The number in common ({}) should be present at ' 'least once in both n_tuple ({}) and pair ({}).' .format(str(common_nb), str(n_tuple), str(pair))) elif len(n_tuple) == 2: if common_nb != n_tuple[0]: # In the case the first number was not the common number, now it is n_tuple = (n_tuple[1], n_tuple[0]) if pair[0] != common_nb: n_tuple += (pair[0], ) else: n_tuple += (pair[1], ) return n_tuple
# -------------------------------------------------------------------------- ## # @brief Will reorganize the raw questions lists into a dictionary # @param q_list The questions' list # @return (q_dict, q_nb) The questions' dictionary + the total number of # questions
[docs]def build_q_dict(q_list): # In q_list, each element is like this: # [{'id': 'multi direct', 'nb':'int'}, ['table_2_9'], 4] # [q[0], q[1], q[2]] q_dict = {} already_unpacked = set() q_nb = 0 for order, q in enumerate(q_list): q_nb += q[2] for n in range(q[2]): q_kind, q_subkind = parse_qid(q[0]['id']) # Here we 'unpack' some special subkinds. if q_subkind in UNPACKABLE_SUBKINDS: already_unpacked |= {q_subkind} elif q_subkind in to_unpack: subk_left = to_unpack[q_subkind] - already_unpacked if not subk_left: already_unpacked -= copy.deepcopy( SUBKINDS_TO_UNPACK[q_subkind]) to_unpack[q_subkind] = copy.deepcopy( SUBKINDS_TO_UNPACK[q_subkind]) subk_left = to_unpack[q_subkind] - already_unpacked s = list(subk_left) random.shuffle(s) q_subkind = s.pop() already_unpacked |= {q_subkind} q_id = '_'.join([q_kind, q_subkind]) q_options = copy.deepcopy(q[0]) q_dict.setdefault(q_id, []) q_dict[q_id] += [(q[1], q_kind, q_subkind, q_options, order)] del q_options['id'] return q_dict, q_nb
# -------------------------------------------------------------------------- ## # @brief Will create a complete and mixed questions' list from q_dict # @param q_dict The questions' dictionary # @return q_list The new mixed questions' list
[docs]def build_mixed_q_list(q_dict, shuffle=True): # q_dict is organized like this: # { 'multi_direct': [(['table_2_9'], 'multi', 'direct', {'nb':'int'}), # (['table_2_9'], 'multi', 'direct', {'nb':'int'})], # 'multi_reversed': [(['table_2_9'], 'multi', 'reversed', {options})], # 'divi_direct': [(['table_2_9'], 'divi', 'direct', {options})], # 'multi_hole': [(['table_2_9'], 'multi', 'hole', {options})], # 'q_id': [(['table_15'], 'kind', 'subkind', {options})], # 'q_id: [(['table_25'], 'kind', 'subkind', {options})], # 'etc.' # } mixed_q_list = [] q_id_box = [key for key in q_dict.keys() for i in range(len(q_dict[key]))] if shuffle: random.shuffle(q_id_box) for q_id in q_id_box: info = q_dict[q_id].pop(0) mixed_q_list += [Q_info(q_id, info[1], info[2], info[0], info[3], info[4])] return mixed_q_list
[docs]def preprocess_variant(q_i): """ Preprocess question's variant (if necessary) :param q_i: the Q_info object, whose fields are 'id,kind,subkind,nb_source,options,order' :type q_i: Q_info (named tuple) """ if q_i.id == 'order_of_operations': default_variant = { 'order_of_operations': {'variant': '0-23,100-87'} } if ('variant' not in q_i.options or ('variant' in q_i.options and q_i.options['variant'] == '')): q_i.options.update(default_variant[q_i.id]) try: variants_to_pick_from = intspan(q_i.options['variant']) except ParseError: raise ValueError('Incorrect variant in xml file: {}' .format(q_i.options['variant'])) raw_query = '(' last = len(variants_to_pick_from.ranges()) - 1 for i, r in enumerate(variants_to_pick_from.ranges()): if r[0] == r[1]: raw_query += 'nb1 = ' + str(r[0]) else: raw_query += '(nb1 >= {} AND nb1 <= {})'.format(r[0], r[1]) if i < last: raw_query += ' OR ' raw_query += ')' q_i.options.update( {'variant': int(shared .order_of_operations_variants_source .next(**{'raw': raw_query})[0])})
[docs]def auto_adjust_nb_sources(nb_sources: list, q_i: namedtuple): """ Automatically adjust nb_sources for certains questions. :param nb_sources: the provided numbers sources :param q_i: the Q_info object (namedtuple), whose fields are 'id,kind,subkind,nb_source,options' """ if q_i.id == 'order_of_operations': if not len(nb_sources) == 2: raise ValueError('There must be two sources for ' 'order_of_operations ' 'questions.') if nb_sources[0].startswith('single') and 'pairs' in nb_sources[1]: single_nb_source, pairs_nb_source = nb_sources elif nb_sources[1].startswith('single') and 'pairs' in nb_sources[0]: pairs_nb_source, single_nb_source = nb_sources else: raise ValueError('One of the two sources for ' 'order_of_operations ' 'questions must be for single numbers, ' 'the other one for pairs.') v = q_i.options['variant'] if 0 <= v <= 3: nb_sources = [single_nb_source, pairs_nb_source] elif 4 <= v <= 7: nb_sources = [pairs_nb_source, single_nb_source] elif 8 <= v <= 15: nb_sources = [pairs_nb_source, pairs_nb_source] elif 16 <= v <= 23: nb_sources = [single_nb_source, pairs_nb_source, single_nb_source] elif 100 <= v <= 107: nb_sources = [pairs_nb_source] elif 108 <= v <= 115: nb_sources = [single_nb_source, pairs_nb_source] elif 116 <= v <= 147: nb_sources = [pairs_nb_source, pairs_nb_source] elif 148 <= v <= 155: nb_sources = [pairs_nb_source] elif 156 <= v <= 187: nb_sources = [single_nb_source, pairs_nb_source] return nb_sources
# -------------------------------------------------------------------------- ## # @brief Determine the # @param q_i The Q_info object
[docs]def get_nb_sources_from_question_info(q_i): nb_sources = [] extra_infos = {'merge_sources': False} questions_sources = q_i.nb_source if len(q_i.nb_source) == 1: if q_i.nb_source[0].startswith('properfraction'): # properfraction_2to3×3to10 means a fraction of numerator and # denominator from 3 to 10 (though numerator will be maximum 1 less # the denominator), both multiplied by a coefficient between 2 and # 3. if '×' not in q_i.nb_source[0]: # No multiplicative coefficient is equivalent to a 1 # properfraction_3to10 is same as properfraction_1to1×3to10 chunks = q_i.nb_source[0].split(sep='_') q_i.nb_source[0] = '{}_{}{}'.format(chunks[0], '1to1×', chunks[1]) bounds = q_i.nb_source[0].split(sep='_')[1] questions_sources = ['intpairs_' + bounds, 'intpairs_' + bounds] extra_infos.update({'merge_sources': True, 'coprime': True}) elif q_i.nb_source[0].startswith('mergedinttriples_'): chunks = q_i.nb_source[0].split(sep='_') if not len(chunks) >= 2: raise ValueError('Incorrect numbers\' source value in xml ' 'file: {}'.format(q_i.nb_source[0])) bounds = chunks[1] if 'to' in bounds: questions_sources = ['intpairs_' + bounds, 'intpairs_' + bounds] else: # We assume bounds consists of a single number, requiring # multiples from the same table. questions_sources = ['multiplesof' + bounds + '_' + chunks[2], 'multiplesof' + bounds + '_' + chunks[2]] extra_infos.update({'merge_sources': True}) elif q_i.nb_source[0].startswith('ext_'): chunks = q_i.nb_source[0][4:].split(sep='_') if chunks[0] == 'proportionality': if chunks[1] == 'quadruplet': questions_sources = ['intpairs_' + chunks[2], 'intpairs_' + chunks[2], 'intpairs_' + chunks[2]] extra_infos.update({'merge_sources': True, 'triangle_inequality': True}) elif ';;' in q_i.nb_source[0]: questions_sources = q_i.nb_source[0].split(sep=';;') questions_sources = auto_adjust_nb_sources(questions_sources, q_i) for nb_sce in questions_sources: tag_to_unpack = nb_source = nb_sce extra_kwargs = {} if nb_source in SOURCES_TO_UNPACK: s = '' stu = copy.copy(SOURCES_TO_UNPACK) if nb_source in ['decimal_and_10_100_1000', 'decimal_and_one_digit']: # __ s = stu[nb_source][q_i.id] else: s = stu[nb_source][q_i.subkind] s = list(s) random.shuffle(s) nb_source = s.pop() if (tag_to_unpack == 'auto_vocabulary' and q_i.subkind in ['addi', 'subtr'] and nb_source == 'intpairs_2to200'): # __ extra_kwargs.update({'suits_for_deci2': 1}) if (nb_source.startswith('intpairs') and q_i.options.get('nb_variant', '').startswith('decimal1')): extra_kwargs.update({'suits_for_deci1': 1}) if (nb_source.startswith('intpairs') and q_i.options.get('nb_variant', '').startswith('decimal2')): extra_kwargs.update({'suits_for_deci2': 1}) nb_sources += [(nb_source, extra_kwargs)] return nb_sources, extra_infos
# -------------------------------------------------------------------------- ## # @brief Increases the disorder of the questions' list # @param l The list # @param sort_key The list's objects' attribute that will be used to # determine whether the order should be changed or not
[docs]def increase_alternation(l, sort_key): if len(l) >= 3: for i in range(len(l) - 2): if getattr(l[i], sort_key) == getattr(l[i + 1], sort_key): if getattr(l[i + 2], sort_key) != getattr(l[i], sort_key): l[i + 1], l[i + 2] = l[i + 2], l[i + 1] return l
[docs]def numbering_device(numbering_kind='disabled'): """ This generator provides "limitless" new items for numbering questions. Possible values of the argument are: - 'disabled': an empty string will be returned - 'numeric': an integer is returned (until the maximal value is reached, but let's consider there won't be that long exercises!) - 'alphabetic': a letter is returned (once the alphabet is over, as a security, the letter is returned doubled, tripled, etc., thought it is not expected to need more than 26 questions in the same exercise.) """ if numbering_kind == 'disabled': while True: yield '' elif numbering_kind == 'numeric': i = 0 while True: yield i + 1 i += 1 elif numbering_kind in ['alphabet', 'alphabetical', 'default']: i = 0 while True: yield alphabet[i % 26] * ((i // 26 + 1)) i += 1
[docs]class Exercise(object):
[docs] def setup(self, **options): self.preset = options.get('preset', 'default') presets = {} if self.preset not in AVAILABLE_PRESETS: warnings.warn('XML Format error: incorrect preset value {}. ' 'Defaulting to \'default\'.'.format(self.preset)) self.preset = 'default' if self.preset == 'default': presets = {'layout_variant': 'default', 'shuffle': 'false', 'q_spacing': 'undefined', 'details_level': 'maximum', 'text_ans': _('Example of detailed solutions:'), 'q_numbering': 'disabled', 'tabular_batch': 0} elif self.preset == 'mental calculation': # /!\ q_numbering for tabular is indeed what happens, but the # value defined below is NOT used, it is hardcoded instead. # TODO: see how to fix this presets = {'layout_variant': 'tabular', 'shuffle': 'true', 'q_spacing': '', 'details_level': 'none', 'text_ans': '', 'q_numbering': 'numeric', 'tabular_batch': 20} self.tabular_batch = presets.get('tabular_batch', 0) self.layout_variant = options.get('layout_variant', presets.get('layout_variant')) if self.layout_variant not in AVAILABLE_LAYOUT_VARIANTS: warnings.warn('XML Format error: incorrect layout_variant {}. ' 'Defaulting to \'default\'.' .format(self.layout_variant)) self.layout_variant = 'default' self.x_layout_unit = options.get('layout_unit', 'cm') self.x_layout = options.get('x_layout', DEFAULT_LAYOUT) self.q_spacing = options.get('q_spacing', presets.get('q_spacing')) self.q_numbering = options.get('q_numbering', presets['q_numbering']) self.shuffle = BOOLEAN[options.get('shuffle', presets.get('shuffle'))]() self.details_level = options.get('details_level', presets.get('details_level')) if self.details_level not in AVAILABLE_DETAILS_LEVELS: warnings.warn('XML Format error: incorrect details_level {}. ' 'Defaulting to \'maximum\'.' .format(self.details_level)) self.details_level = 'maximum' self.start_number = options.get('start_number', 1) if not is_integer(self.start_number): raise TypeError('Got: ' + str(type(self.start_number)) + ' instead of an integer') if self.start_number < 1: raise ValueError(str(self.start_number) + 'should be >= 1') x_spacing = options.get('spacing', '') if x_spacing == 'newline': self.x_spacing = {'exc': shared.machine.write_new_line(), 'ans': shared.machine.write_new_line()} elif x_spacing == 'newline_twice': self.x_spacing = {'exc': shared.machine.write_new_line() + shared.machine.write_new_line(), 'ans': shared.machine.write_new_line() + shared.machine.write_new_line()} elif x_spacing == '': # do not remove otherwise you'll get empty addvspace instead self.x_spacing = {'exc': '', 'ans': ''} elif x_spacing == 'jump to next page': self.x_spacing = \ {'exc': shared.machine.write_jump_to_next_page(), 'ans': shared.machine.write_jump_to_next_page()} else: self.x_spacing = \ {'exc': shared.machine.addvspace(height=x_spacing), 'ans': shared.machine.addvspace(height=x_spacing)} if options.get('x_config', None) is not None: spacing_w = options.get('x_config').get('spacing_w', 'undefined') spacing_a = options.get('x_config').get('spacing_a', 'undefined') for key, s in zip(['exc', 'ans'], [spacing_w, spacing_a]): if s != 'undefined': if s == 'newline': self.x_spacing.update( {key: shared.machine.write_new_line()}) elif s == 'newline_twice': self.x_spacing.update( {key: shared.machine.write_new_line() + shared.machine.write_new_line()}) elif s == '': # do not remove otherwise you'll get empty addvspace self.x_spacing.update({key: ''}) elif s == 'jump to next page': self.x_spacing.update( {key: shared.machine.write_jump_to_next_page()}) else: self.x_spacing.update( {key: shared.machine.addvspace(height=s)}) self.min_row_height = options.get('x_config').get('min_row_height') self.text = {'exc': options.get('text_exc', ''), 'ans': options.get('text_ans', presets['text_ans'])} if self.text['exc'] != '': self.text['exc'] = _(self.text['exc']) if self.text['ans'] != '': self.text['ans'] = _(self.text['ans'])
def __init__(self, **options): if 'data' in options: # TODO: all these options for setup may be simplified once # reading a xml sheet is not required anymore: self.x_layout # may be directly set in setup(), via the same instruction than # below and the spacing_w and spacing_a attributes, inside setup(), # may be checked from self.x_layout directlyn there's no need for # x_config anymore. x_layout = read_layout(options['data'].get('layout', {})) x_config = {'spacing_w': x_layout.get('spacing_w', 'undefined'), 'spacing_a': x_layout.get('spacing_a', 'undefined'), 'min_row_height': x_layout.get('min_row_height')} self.setup(x_layout=x_layout, x_config=x_config, **{k: options['data'][k] for k in options['data'] if (not k.startswith('question') and not k.startswith('mix'))}) q_list = build_questions_list(options['data']) else: # Setup self attributes according to options self.setup(**options) q_list = options.get('q_list') self.min_row_height = 0.8 # default value for xml files, whose # support will be removed later on # From q_list, we build a dictionary and then a complete questions' # list: q_dict, self.q_nb = build_q_dict(q_list) # in case of mental calculation exercises we shuffle the questions # (or if the user has set shuffle to 'true' in the <exercise> section) if self.shuffle: for key in q_dict: random.shuffle(q_dict[key]) mixed_q_list = build_mixed_q_list(q_dict, shuffle=self.shuffle) # in case of mental calculation exercises we increase alternation if self.shuffle and self.preset == 'mental_calculation': mixed_q_list = increase_alternation(mixed_q_list, 'id') mixed_q_list.reverse() mixed_q_list = increase_alternation(mixed_q_list, 'id') if not self.shuffle: mixed_q_list = sorted(mixed_q_list, key=lambda qinfo: qinfo.order) # mixed_q_list contains Q_info objects: # [('id', 'kind', 'subkind', 'nb_source', 'options'), # ('q_id', 'q', 'id', 'table_15', {'nb':}), # ('multi_direct', 'multi', 'direct', ['table_2_9'], {'nb':}), # etc. # ] # Now, we generate the numbers & questions, by type of question first self._questions_list = [] last_draw = {} numbering = numbering_device(self.q_numbering) log = settings.dbg_logger.getChild('Exercise.init') for q in mixed_q_list: q_number = next(numbering) log.debug('QUESTION # {} -------------------------------- {} ---' '-----------------------------'.format(q_number, q.id)) preprocess_variant(q) (nbsources_xkw_list, extra_infos) = \ get_nb_sources_from_question_info(q) nb_to_use = tuple() common_nb = None for (i, (nb_source, xkw)) in enumerate(nbsources_xkw_list): log.debug('nb_source = {}'.format(nb_source)) if last_draw.get(nb_source) is None: last_draw[nb_source] = None # Handle all nb sources for ONE question # So, i is the number of the source for the SAME question # and it gets reset (to 0) at each new question. if i == 1 and extra_infos['merge_sources']: # i == 1 hence we are on the *second* source for the same # question. if extra_infos.get('coprime', False): # Now last_draw shouldn't need to get reordered, maybe # remove the sorted() call. # Coprimes being about integers, int() is used as sort # key. last_draw[nb_source] = sorted(last_draw[nb_source], key=int) lb2, hb2 = nb_source.split(sep='×')[1].split(sep='to') lb2, hb2 = int(lb2), int(hb2) span = [i + lb2 for i in range(hb2 - lb2)] coprimes = [str(n) for n in coprimes_to( int(last_draw[nb_source][-1]), span)] second_couple_drawn = shared.mc_source\ .next(nb_source, nb1=last_draw[nb_source][0], nb2_in=coprimes, qkw=q.options, **get_q_modifier(q.id, nb_source), **xkw) else: either = last_draw[nb_source] if q.options.get('force_table', None) is not None: either = [q.options.get('force_table')] second_couple_drawn = shared.mc_source\ .next(nb_source, either_nb1_nb2_in=either, qkw=q.options, **get_q_modifier(q.id, nb_source), **xkw) common_nb = get_common_nb_from_pairs_pair( (nb_to_use, second_couple_drawn)) nb_to_use = merge_pair_to_tuple(nb_to_use, second_couple_drawn, common_nb) elif i > 1 and extra_infos['merge_sources']: if (i == 2 and extra_infos.get('triangle_inequality', False)): # __ new_couple_drawn = shared.mc_source\ .next(nb_source, triangle_inequality=nb_to_use, qkw=q.options, **get_q_modifier(q.id, nb_source), **xkw) else: new_couple_drawn = shared.mc_source\ .next(nb_source, either_nb1_nb2_in=[common_nb], qkw=q.options, **get_q_modifier(q.id, nb_source), **xkw) nb_to_use = merge_pair_to_tuple(nb_to_use, new_couple_drawn, common_nb) else: # i == 0 (i.e. first source for this question) not_in = last_draw[nb_source] either = None # default value, no effect if (nb_source == 'polygons' or nb_source.startswith('int_quintuples')): not_in = None if q.options.get('force_table', None) is not None: not_in = None either = [q.options.get('force_table')] drawn = shared.mc_source.next(nb_source, not_in=not_in, either_nb1_nb2_in=either, qkw=q.options, **get_q_modifier( q.id, nb_source), **xkw) nb_to_use += drawn known_elts = set() last_draw[nb_source] = [] for n in nb_to_use: if (n in known_elts or not (isinstance(n, int) or isinstance(n, str))): continue last_draw[nb_source].append(str(n)) known_elts.add(n) if nb_source in ['decimal_and_10_100_1000_for_divi', 'decimal_and_10_100_1000_for_multi']: # __ q.options['10_100_1000'] = True if self.q_spacing != 'undefined' and 'spacing' not in q.options: q.options.update({'spacing': self.q_spacing}) q.options.update({'details_level': self.details_level, 'preset': self.preset, 'x_layout_variant': self.layout_variant}) self._questions_list += \ [Question(q.id, **q.options, nb_source=nb_source, build_data=nb_to_use, number_of_the_question=q_number, )] shared.number_of_the_question = 0 @property def questions_list(self): return self._questions_list @questions_list.setter def questions_list(self, o): self._questions_list = o
[docs] def to_str(self, ex_or_answers): M = shared.machine result = '' if self.layout_variant == 'default': layout = self.x_layout[ex_or_answers] if self.text[ex_or_answers] != "": result += self.text[ex_or_answers] result += M.addvspace(height='10.0pt') q_n = 0 for k in range(int(len(layout) // 2)): if layout[2 * k] is None: how_many = layout[2 * k + 1] if layout[2 * k + 1] in ['all_left', 'all']: how_many = len(self.questions_list) - q_n for i in range(how_many): result += self.questions_list[q_n]\ .to_str(ex_or_answers) if ex_or_answers == 'ans' and i < how_many - 1: result += M.addvspace(height='20.0pt') q_n += 1 elif (layout[2 * k] == 'jump' and layout[2 * k + 1] == 'next_page'): result += M.write_jump_to_next_page() else: nb_of_cols = len(layout[2 * k]) - 1 col_widths = layout[2 * k][1:] nb_of_lines = layout[2 * k][0] undefined_nb_of_lines = False if nb_of_lines == '?': undefined_nb_of_lines = True if layout[2 * k + 1] == 'all': nb_of_q_per_row = nb_of_cols else: nb_of_q_per_row = sum(layout[2 * k + 1][j] for j in range(nb_of_cols)) nb_of_lines = \ len(self.questions_list) // nb_of_q_per_row \ + (0 if not len(self.questions_list) % nb_of_q_per_row else 1) content = [] for i in range(int(nb_of_lines)): for j in range(nb_of_cols): if layout[2 * k + 1] == 'all': nb_of_q_in_this_cell = 1 else: k = 0 if undefined_nb_of_lines else i nb_of_q_in_this_cell = \ layout[2 * k + 1][k * nb_of_cols + j] cell_content = "" for n in range(nb_of_q_in_this_cell): empty_cell = False if q_n >= len(self.questions_list): cell_content += " " empty_cell = True else: cell_content += \ self.questions_list[q_n].\ to_str(ex_or_answers) if ex_or_answers == 'ans' and not empty_cell: vspace = '' \ if len(cell_content) <= 25 \ else cell_content[-25:] newpage = '' \ if len(cell_content) <= 9 \ else cell_content[-9:] cell_content += M.write_new_line( check=cell_content[-2:], check2=vspace, check3=newpage) q_n += 1 content += [cell_content] options = {'unit': self.x_layout_unit} result += M.write_layout((nb_of_lines, nb_of_cols), col_widths, content, **options) return result + self.x_spacing[ex_or_answers] elif self.layout_variant == 'slideshow': if ex_or_answers == 'exc': for q in self.questions_list: result += M.write_frame(q.to_str('exc'), duration=q.transduration, numbering=q.displayable_number) elif ex_or_answers == 'ans': for q in self.questions_list: if q.substitutable_question_mark: content = q.to_str('exc') + SLIDE_CONTENT_SEP \ + q.to_str('exc')\ .replace(COLORED_QUESTION_MARK, COLORED_ANSWER.format( text='{' + q.to_str('ans') + '}')) else: content = q.to_str('exc') + SLIDE_CONTENT_SEP \ + q.to_str('exc') \ + r' \par ' + _('Answer: ') \ + COLORED_ANSWER.format( text='{' + q.to_str('ans') + '}') required.package['xcolor'] = True required.options['xcolor'].add('dvipsnames') result += M.write_frame(content, only=True, numbering=q.displayable_number) return result # default tabular option: elif self.layout_variant == 'tabular': # tabular_batch can be used to define how many questions will be # printed in one table on one page. If it is set to 0, then all # questions will be put in the same tabular. Otherwise, questions # will be grouped by the number defined as tabular_batch. if self.tabular_batch: batches_nb = (self.q_nb - 1) // self.tabular_batch + 1 else: batches_nb = 1 self.tabular_batch = self.q_nb for bn in range(batches_nb): qn_bounds = [bn * self.tabular_batch, min((bn + 1) * self.tabular_batch, self.q_nb)] q = [self.questions_list[i].to_str('exc') for i in range(*qn_bounds)] a = [COLORED_ANSWER.format( text='{' + self.questions_list[i].to_str('ans') + '}') for i in range(*qn_bounds)]\ if ex_or_answers == 'ans' \ else [self.questions_list[i].to_str('hint') for i in range(*qn_bounds)] required.package['xcolor'] = True required.options['xcolor'].add('dvipsnames') n = [M.write(str(i + 1) + ".", emphasize='bold') for i in range(*qn_bounds)] content = [elt for triple in zip(n, q, a) for elt in triple] lines_nb = min(self.tabular_batch, self.q_nb - bn * self.tabular_batch) result += M.write_layout((lines_nb, 3), [0.5, 14.25, 3.75], content, borders='penultimate', justify=['left', 'left', 'center'], center_vertically=True, min_row_height=self.min_row_height) if shared.enable_js_form and ex_or_answers == 'exc': # Requiring amsmath because of the \text{{ }} below required.package['amsmath'] = True result += (r""" \PushButton[name=clearbutton,bordercolor={{0.5 0.5 0.5}}, onclick={{var qNumber = {q_number}; for (var i = 1; i <= qNumber; i++) {{ this.getField("ans" + i.toString()).value=""; this.removeField("c" + i.toString()); }} this.getField("mark").value="{empty_score_line}"; }} ]{{ {clearbutton_caption} }} \text{{ }} \hfill \PushButton[name=checkbutton,bordercolor={{0 0 0}}, onclick={{function modulo (a, b) {{ return a - b * Math.floor(a / b); }}; function reduce (numerator, denominator) {{ var gcd = function gcd (a, b) {{ return b ? gcd(b, modulo(a, b)) : a; }}; gcd = gcd(numerator, denominator); return [numerator / gcd, denominator / gcd]; }}; function isInt (value) {{ return !isNaN(value) && parseInt(Number(value)) == value && !isNaN(parseInt(value, 10)); }}; function isPowerOf10 (value) {{ if (value < 0) return isPowerOf10(-value); if (value == 1 || value == 10) {{ return true; }} else if (value < 1) {{ return isPowerOf10(value * 10); }} else if (value > 10) {{ return isPowerOf10(value / 10); }} else {{ return false; }} }} var qNumber = {q_number}; var answers = {list_of_answers}; var count = 0; var color_green = ["RGB", 0.2265625, 0.49609375, """ r"""0.195315]; var color_red = ["RGB", 0.7109375, 0.1875, 0.109375]; for (var i = 1; i <= qNumber; i++) {{ var istr = i.toString(); var ansfield = this.getField("ans" + istr); var ansfield0 = 0; var ansbox = ansfield.rect; var crect = [ansbox[2] - 13, ansbox[3] + 20, """ r"""ansbox[2] - 3, ansbox[3]]; if (answers[i - 1][0].constructor == Array) {{ crect = [ansbox[2] + 8, ansbox[3] + 20, """ r"""ansbox[2] + 18, ansbox[3]]; ansfield0 = this.getField("ans" + istr + "a"); }} this.addField("c" + istr, "text", 0, crect); var checkfield = this.getField("c" + istr); checkfield.readonly = true; var found = false; if (answers[i - 1][0].constructor == Array) {{ for (var j = 0; j < answers[i - 1].length; ++j) {{ if ((ansfield0.value == decodeURIComponent(""" r"""escape(answers[i - 1][j][0]))) && (ansfield.value == decodeURIComponent(""" r"""escape(answers[i - 1][j][1])))) {{ found = true; }} }} }} else {{ for (var j = 0; j < answers[i - 1].length; ++j) {{ if (ansfield.value == decodeURIComponent(""" r"""escape(answers[i - 1][j]))) {{ found = true; }} if ((!found) && (answers[i - 1][j].indexOf(" == ") !== -1)) {{ var chunks = answers[i - 1][j].split(" == "); if ((chunks[0] == "any_fraction" """ r"""|| chunks[0] == "any_decimal_fraction") && (ansfield.value.indexOf("/") !== -1)) {{ var nd = ansfield.value.split("/"); if ((nd.length == 2) && isInt(nd[0]) && isInt(nd[1])) {{ var n = Number(nd[0]); var d = Number(nd[1]); if (!(chunks[0] == "any_decimal_fract""" r"""ion" && !(isPowerOf10(d)))) {{ var r = reduce(n, d); var N = r[0].toString(); var D = r[1].toString(); if (chunks[1] === N + "/" + D) """ r"""found = true; }} }} }} }} }} }} if (found) {{ checkfield.textColor = color_green; checkfield.value = "{good_answer}"; count = count + 1; }} else {{ checkfield.textColor = color_red; checkfield.value = "{wrong_answer}"; }} }} this.getField("mark").value = {score_line}; }} ]{{ {checkbutton_caption} }}""")\ .format(q_number=self.q_nb, clearbutton_caption=_('Clear everything'), checkbutton_caption=_('Validate'), list_of_answers=[self.questions_list[i].to_str( 'js_ans') for i in range(*qn_bounds)], good_answer=_('Please translate this into ' 'only one letter to mean ' '"correct answer"'), wrong_answer=_('Please translate this into ' 'only one letter to mean ' '"wrong answer"'), empty_score_line=_('Score: ... / {q_number}') .format(q_number=self.q_nb), score_line=_('"Score: " + count.toString() ' '+ " / {q_number}"') .format(q_number=self.q_nb) ) return result