(no commit message)
[atutor.git] / mods / _core / imsqti / classes / QTIParser.class.php
1 <?php
2 /****************************************************************/
3 /* ATutor                                                                                                               */
4 /****************************************************************/
5 /* Copyright (c) 2002-2009                                                                              */
6 /* Inclusive Design Institute                                   */
7 /* http://atutor.ca                                                                                             */
8 /*                                                              */
9 /* This program is free software. You can redistribute it and/or*/
10 /* modify it under the terms of the GNU General Public License  */
11 /* as published by the Free Software Foundation.                                */
12 /****************************************************************/
13 // $Id$
14
15 //Constances
16 define('AT_QTI_REPONSE_GRP',    1);
17 define('AT_QTI_REPONSE_LID',    2);
18 define('AT_QTI_REPONSE_STR',    3);
19
20 /**
21 * QTIParser
22 * Class for parsing XML language info and returning a QTI Object
23 * @access       public
24 * @author       Harris Wong
25 */
26 class QTIParser {
27         // all private
28         var $parser; // the XML handler
29         var $qti_type; // QTI specification versoin, imsqti_xmlv1p1, imsqti_item_xmlv2p1, imsqti_xmlv1p2
30         var $character_data; // tmp variable for storing the data
31         var $element_path; // array of element paths (basically a stack)
32         var $title;     //title for this question test
33         var $q_identifiers      = array();              //The identifier of the choice. This identifier must not be used by any other choice or item variable.
34         var $question = '';                                     //question of this QTI
35         var $response_type      = array();              //detects what type of question this would be.
36         var $relative_path      = '';                   //the relative path to all resources in this xml.
37
38         //stacks
39         var $choices            = array();      //answers array that keep tracks of all the correct answers
40         var $groups                     = array();      //groups for matching, the left handside to match with the different choices
41         var $attributes         = array();      //tag attribute
42         var $answers            = array();      //correct answers 
43         var $response_label = array();  //temporary holders for response labels
44         var $field_label        = array();              //fields label
45         var $field_entry        = array();      //fields entry
46         var $feedback           = array();              //question feedback
47         var $item_num           = 0;            //item number
48         var $items                      = array();      //stacks of media items, ie. img, embed, ahref etc. 
49         var $qmd_itemtype       = -1;           //qmd flag
50         var $temp_answer        = array();      //store the temp answer stack
51         var $answers_for_matching       = array();
52         var $weights            = array();      //the weight of each question
53
54         //constructor
55         function QTIParser($qti_type='') {
56                 $this->qti_type = $qti_type;
57                 $this->parser = xml_parser_create(); 
58
59                 xml_set_object($this->parser, $this);
60                 xml_parser_set_option($this->parser, XML_OPTION_CASE_FOLDING, false); /* conform to W3C specs */
61                 xml_set_element_handler($this->parser, 'startElement', 'endElement');
62                 xml_set_character_data_handler($this->parser, 'characterData');
63         }
64
65         // public
66         // @return      true if parsed successfully, false otherwise
67         function parse($xml_data) {
68                 $this->element_path   = array();
69                 $this->character_data = '';
70                 xml_parse($this->parser, $xml_data, TRUE);
71
72                 //Loop thru each item and replace if existed
73                 foreach ($this->answers_for_matching as $afm_k => $afk_v){
74                         if (!empty($this->answers_for_matching[$afm_k])){
75                                 $this->answers[$afm_k] = $afk_v;
76                         }
77                 }
78
79                 if(in_array('questestinterop', $this->element_path) ||
80                         in_array('assessment', $this->element_path)){
81                         //this is a v2.1+ package
82                         return false;
83                 } else {
84                         return true;
85                 }
86         }
87
88         // private
89         function startElement($parser, $name, $attributes) {
90                 global $msg;
91 //              debug($attributes, $name );
92                 //save attributes.
93                 switch($name) {
94                         case 'section':
95                                 $this->title = $attributes['title'];
96                                 break;
97                         case 'response_lid':
98                                 if ($this->response_type[$this->item_num] <= 0) {
99                                         $this->response_type[$this->item_num] = AT_QTI_REPONSE_LID;
100                                 }
101                         case 'response_grp':
102                                 if ($this->response_type[$this->item_num] <= 0) {
103                                         $this->response_type[$this->item_num] = AT_QTI_REPONSE_GRP;
104                                 }
105                         case 'response_str':
106                                 $this->attributes[$this->item_num][$name]['ident'] = $attributes['ident'];
107                                 $this->attributes[$this->item_num][$name]['rcardinality'] = $attributes['rcardinality'];
108                                 if ($this->response_type[$this->item_num] <= 0) {
109                                         $this->response_type[$this->item_num] = AT_QTI_REPONSE_STR;
110                                 }
111                                 break;
112                         case 'response_label':
113                                         if(!isset($this->choices[$this->item_num][$attributes['ident']])){
114                                                 if (!is_array($this->response_label[$this->item_num])){
115                                                         $this->response_label[$this->item_num] = array();       
116                                                 }
117                                                 array_push($this->response_label[$this->item_num], $attributes['ident']);
118                                         }
119                                 break;
120                         case 'varequal':
121                                 $this->attributes[$this->item_num][$name]['respident'] = $attributes['respident'];
122                                 break;
123                         case 'setvar':
124                                 $this->attributes[$this->item_num][$name]['varname'] = $attributes['varname'];
125                                 break;
126                         case 'render_choice':
127                                 $this->attributes[$this->item_num][$name]['shuffle'] = $attributes['shuffle'];
128                                 $this->attributes[$this->item_num][$name]['minnumber'] = $attributes['minnumber'];
129                                 $this->attributes[$this->item_num][$name]['maxnumber'] = $attributes['maxnumber'];
130                                 break;
131                         case 'render_fib':
132                                 $rows = intval($attributes['rows']);
133                                 $property = 1;
134
135                                 //1,2,3,4 according to tools/tests/create_question_long.php
136                                 if ($rows == 1){
137                                         $property = 2;
138                                 } elseif ($rows > 1 && $rows <= 5){
139                                         $property = 3;
140                                 } elseif ($rows > 5){
141                                         $property = 4;
142                                 }
143                                 $this->attributes[$this->item_num][$name]['property'] = $property;
144                                 break;
145                         case 'matimage':
146                                 $this->attributes[$this->item_num][$name]['imagtype'] = $attributes['imagtype'];
147                                 $this->attributes[$this->item_num][$name]['uri'] = $attributes['uri'];
148                                 break;
149                         case 'mataudio':
150                                 $this->attributes[$this->item_num][$name]['audiotype'] = $attributes['audiotype'];
151                                 $this->attributes[$this->item_num][$name]['uri'] = $attributes['uri'];
152                                 break;
153                         case 'matvideo':
154                                 $this->attributes[$this->item_num][$name]['videotype'] = $attributes['videotype'];
155                                 $this->attributes[$this->item_num][$name]['uri'] = $attributes['uri'];
156                                 break;
157                         case 'matapplet':
158                                 $this->attributes[$this->item_num][$name]['uri'] = $attributes['uri'];
159                                 $this->attributes[$this->item_num][$name]['width'] = intval($attributes['width']);
160                                 $this->attributes[$this->item_num][$name]['height'] = intval($attributes['height']);
161                                 break;
162                         case 'setvar':
163                                 $this->attributes[$this->item_num][$name]['varname'] = $attributes['varname'];
164                                 $this->attributes[$this->item_num][$name]['action'] = $attributes['action'];
165                                 break;
166                         case 'itemproc_extension':
167                                 if (preg_match('/imsqti_xmlv1p2\/imscc_xmlv1p0(.*)/', $this->qti_type)){
168                                         $msg->addError('QTI_WRONG_PACKAGE');
169                                 }
170                                 break;
171                 }
172                 array_push($this->element_path, $name);
173    }
174
175         // private
176         /* called when an element ends */
177         /* removed the current element from the $path */
178         function endElement($parser, $name) {
179                 global $msg;
180                 //check element path
181                 $current_pos = count($this->element_path) - 1;
182                 $last_element = $this->element_path[$current_pos - 1];
183
184                 switch($name) {
185                         case 'item':
186                                 $this->item_num++;
187                                 break;
188                         case 'mattext':
189                                 $this->mat_content[$this->item_num] .= $this->reconstructRelativePath($this->character_data);
190                                 break;
191                         case 'matimage':
192                                 $this->mat_content[$this->item_num] .= '<img src="'.$this->attributes[$this->item_num][$name]['uri'].'" alt="Image Not loaded:'.$this->attributes[$this->item_num][$name]['uri'].'" />';
193                                 break;
194                         case 'mataudio':
195                                 $this->mat_content[$this->item_num] .= '<embed SRC="'.$this->attributes[$this->item_num][$name]['uri'].'" autostart="false" width="145" height="60"><noembed><bgsound src="'.$this->attributes[$this->item_num][$name]['uri'].'"></noembed></embed>';
196                                 break;
197                         case 'matvideo':
198                                 if ($this->attributes[$this->item_num][$name]['videotype'] == 'type/swf'){
199                                         $this->mat_content[$this->item_num] .= '<object type="application/x-shockwave-flash" data="' . $this->attributes[$this->item_num][$name]['uri'] . '" width="550" height="400"><param name="movie" value="'. $this->attributes[$this->item_num][$name]['uri'] .'" /></object>';                                  
200                                 } elseif ($this->attributes[$this->item_num][$name]['videotype'] == 'type/mov'){
201                                         $this->mat_content[$this->item_num] .= '<object classid="clsid:02BF25D5-8C17-4B23-BC80-D3488ABDDC6B" width="550" height="400" codebase="http://www.apple.com/qtactivex/qtplugin.cab"><param name="src" value="'. $this->attributes[$this->item_num][$name]['uri'] . '" /><param name="autoplay" value="true" /><param name="controller" value="true" /><embed src="' . $this->attributes[$this->item_num][$name]['uri'] .'" width="550" height="400" controller="true" pluginspage="http://www.apple.com/quicktime/download/"></embed></object>';
202                                 }
203                                 break;
204                         case 'matapplet':
205                                 (($this->attributes[$this->item_num][$name]['width'] != 0)? $width = $this->attributes[$this->item_num][$name]['width'] : $width = 460);
206                                 (($this->attributes[$this->item_num][$name]['height'] != 0)? $height = $this->attributes[$this->item_num][$name]['height'] : $height = 160);
207                                 $this->mat_content[$this->item_num] .= '<applet code="'.$this->attributes[$this->item_num][$name]['uri'].'" width="'.$width.'" height="'.$height.'" alt="Applet not loaded."></applet>';
208                                 break;
209                         case 'material':
210                                 //check who is mattext's ancestor, started from the most known inner layer
211                                 if (in_array('response_label', $this->element_path)){
212                                         if(!in_array($this->mat_content, $this->choices)){
213                                                 //This is one of the choices.
214                                                 if (!empty($this->response_label[$this->item_num])){
215                                                         $this->choices[$this->item_num][array_pop($this->response_label[$this->item_num])] = $this->mat_content[$this->item_num];
216                                                 }
217                                         }
218                                 } elseif (in_array('response_grp', $this->element_path) || in_array('response_lid', $this->element_path)){
219                                         //for matching, where there are groups
220                                         //keep in mind that Respondus handles this by using response_lid
221                                         $this->groups[$this->item_num][] = $this->reconstructRelativePath($this->mat_content[$this->item_num]);
222 //                                      debug($this->character_data, 'harris - groups');
223                                 } elseif (in_array('presentation', $this->element_path)){
224                                         $this->question[$this->item_num] = $this->reconstructRelativePath($this->mat_content[$this->item_num]);
225                                 } elseif (in_array('itemfeedback', $this->element_path)){
226                                         $this->feedback[$this->item_num] = $this->mat_content[$this->item_num];
227                                 }
228                                 //once material is closed, reset the mat_content variable.
229                                 $this->mat_content[$this->item_num] = '';
230                                 break;
231                         case 'varequal':
232                                 //stores the answers (either correct or incorrect) into a stack
233                                 $this->temp_answer[$this->attributes[$this->item_num][$name]['respident']]['name'][] = $this->character_data;
234                                 //responses handling, remember to save the answers or match them up
235                                 if (!is_array($this->answers[$this->item_num])){
236                                         $this->answers[$this->item_num] = array();
237                                 }
238                                 array_push($this->answers[$this->item_num], $this->reconstructRelativePath($this->character_data));
239                                 break;
240                         case 'setvar':
241                                 $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['value'][] = $this->character_data;
242                                 $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['attribute'][] = $this->attributes[$this->item_num]['setvar']['varname'];
243                                 break;
244                         case 'respcondition':
245                                 if (empty($this->temp_answer)) {
246                                         break;
247                                 }
248
249                                 //closing this tag means a selection of choices have ended.  Assign the correct answer in this case.
250                                 $tv = $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']];
251 //                              debug($tv, 'harris'.$this->item_num);
252 //                              debug($this->answers_for_matching[$this->item_num], 'answers');
253
254                                 //If matching, then attribute = 'Respondus_correct'; otherwise it is 'que_score'
255                                 if ($this->getQuestionType($this->item_num) == 5){
256                                         if ($tv['answerAdded']!=true && !empty($tv['attribute'])){
257                                                 foreach ($tv['attribute'] as $att_id => $att_value){
258                                                         //Handles Respondus' (and blakcboard, angels, etc) responses schemas
259                                                         if (strtolower($att_value)=='respondus_correct'){
260                                                                 //Then this is the right answer
261                                                                 if (!is_array($this->answers_for_matching[$this->item_num])){
262                                                                         $this->answers_for_matching[$this->item_num] = array();
263                                                                 }
264                                                                 //The condition here is to check rather the answers have been duplicated, otherwise the indexing won't be right.
265                                                                 //sizeof[answers] != sizeof[questions], then the index matching is wrong.
266                                                                 //Created a problem though, which is then many-to-1 matching fails, cuz answers will be repeated.
267                                                                 //Sep 2,08, Fixed by adding a flag into the array
268         //                                                      if (!in_array($tv['name'][$att_id], $this->answers_for_matching[$this->item_num])){
269                                                                         array_push($this->answers_for_matching[$this->item_num], $tv['name'][$att_id]);
270                                                                         $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['answerAdded'] = true;
271                                                                         
272                                                                         //add mark
273                                                                         $this->weights[$this->item_num] = floatval($tv['value'][$att_id]);
274         //                                                      } 
275                                                                 break;
276                                                         } 
277                                                 }
278                                         }
279                                 } else {
280                                         $pos = sizeof($tv['value']) - 1;        //position of the last entry of the "temp answer's value" array
281                                         //Retrieve the last entry of the "temp answer's value" array
282                                         $current_answer = $tv['value'][$pos];
283                                         if (floatval($current_answer) > 0){
284                                                 if (!is_array($this->answers_for_matching[$this->item_num])){
285                                                         $this->answers_for_matching[$this->item_num] = array();
286                                                 }                                                       
287 //                                                      if (!in_array($tv['name'][$val_id], $this->answers_for_matching[$this->item_num])){
288                                                         array_push($this->answers_for_matching[$this->item_num], $tv['name'][$pos]);
289                                                         
290                                                         //add mark
291                                                         $this->weights[$this->item_num] += floatval($current_answer);
292 //                                                      } 
293                                         }
294                                 } 
295                                 break;
296                         case 'fieldlabel':
297                                 $this->field_label[$this->item_num] = $this->character_data;
298                                 break;
299                         case 'fieldentry':
300                                 $this->field_entry[$this->item_num][$this->field_label[$this->item_num]] = $this->character_data;
301                                 break;
302                         case 'qmd_itemtype':
303                                 //Deprecated as of QTI 1.2.
304                                 if (empty($this->field_entry[$this->item_num][$name])){
305                                         $this->field_entry[$this->item_num][$name] = $this->character_data;
306                                 } 
307                                 break;
308                         default:
309                                 break;
310                 }
311 //              debug($this->element_path, "Ele Path");
312
313                 //pop stack and reset character data, o/w it will stack up
314                 array_pop($this->element_path);
315                 $this->character_data = '';
316         }
317
318         // private      
319         function characterData($parser, $data){
320                 global $addslashes;
321                 if (trim($data)!=''){
322                         $this->character_data .= $addslashes(preg_replace('/[\t\0\x0B(\r\n)]*/', '', $data));
323 //                      $this->character_data .= trim($data);
324                 }
325         }
326
327         /*
328          * This function returns the question type of this XML.
329          * @access      public 
330          * @param       the item_num
331          * @return  1: m/c
332      *          2: t/f
333      *          3: open ended question
334      *          4: likert
335      *          5: s match
336      *          6: order
337      *          7: m/a
338      *          8: g match
339      *          false for not found.
340          */
341         function getQuestionType($item_num){
342                 switch ($this->field_entry[$item_num]['qmd_questiontype']){
343                         case 'Multiple-choice':
344                                 //1, 4
345                                 //likert have no answers
346                                 if (empty($this->answers)){
347                                         return 4;
348                                 }
349                                 return 1;
350                                 break;
351                         case 'True/false':
352                                 return 2;
353                                 break;
354                         case 'FIB-string':
355                                 return 3;
356                                 break;
357                         case 'Drag-and-drop':
358                                 return 5;
359                                 break;
360                         case 'Multiple-response':
361                                 return 7;
362                                 break;
363                 } 
364
365                 switch ($this->field_entry[$item_num]['qmd_itemtype']){
366                         case 'Matching':
367                                 //matching
368                                 return 5;
369                                 break;
370                 }
371
372                 //handles CC packages
373                 switch ($this->field_entry[$item_num]['cc_profile']){
374                         case 'cc.multiple_choice.v0p1':
375                                 return 1;
376                                 break;
377                         case 'cc.true_false.v0p1':
378                                 return 2;
379                                 break;
380                         case 'cc.fib.v0p1':
381                                 return 3;
382                                 break;
383                         case 'cc.multiple_response.v0p1':
384                                 return 7;
385                                 break;
386                 }
387         
388
389                 //Check if this is an ordering, or matching
390                 $response_obj;
391                 switch ($this->response_type[$item_num]){
392                         case AT_QTI_REPONSE_LID:
393                                 $response_obj = $this->attributes[$item_num]['response_lid'];
394                                 break;
395                         case AT_QTI_REPONSE_GRP:
396                                 $response_obj = $this->attributes[$item_num]['response_grp'];
397                                 break;
398                         case AT_QTI_REPONSE_STR:
399                                 $response_obj = $this->attributes[$item_num]['response_str'];
400                                 return 3;       //no need to parse the rcardinality?
401                                 break;
402                 }
403                 if ($response_obj['rcardinality'] == 'Ordered'){
404                         return 6;
405                 } elseif ($response_obj['rcardinality'] == 'Multiple'){
406                         //TODO Multiple answers, Simple matching and Graphical matching
407                         if (empty($this->field_entry[$item_num])){
408                                 return 7;
409                         }
410                         return 5;
411                 } elseif ($response_obj['rcardinality'] == 'Single'){
412                         return 1; //assume mc
413                 }
414
415                 //None found.
416                 return false;
417         }
418
419
420         //set relative path
421         //must be used before calling parse.  Otherwise it will be null.
422         //private
423         function setRelativePath($path){
424                 if ($path != ''){
425                         if ($path[-1] != '/'){
426                                 $path .= '/'; 
427                         }
428                         $this->relative_path = $path;
429                 }
430         }
431
432
433         //private
434         //when importing, the path of the images are all changed.  Have to parse them out and add the extra path in.
435         //No longer needed to reconstruct, just needed to save the path, as of Aug 25th, 08.  Decided to overwrite files if the same name exist.  
436         function reconstructRelativePath($path){
437                 //match img tag, all.
438 //              if (preg_match_all('/\<img(\s[^\>])*\ssrc\=[\\\\]?\"([^\\\\^\"]+)[\\\\]?\".*\/?\>/i', $path, $matches) > 0){
439 //fixes multiple image tags within a $path
440                 if (preg_match_all('/\<img(\s[\w^img]+\=[\\\\]?\"[^\\\\^\"]+[\\\\]?\")*\ssrc\=[\\\\]?\"([^\\\\^\"]+)[\\\\]?\"/i', $path, $matches) > 0){
441                         foreach ($matches[2] as $k=>$v){
442                                 if(strpos($v, 'http://')===false && !in_array($v, $this->items)) {
443                                         $this->items[] = $v;    //save the url of this media.
444         //                              $path = str_replace($v, $this->relative_path.$v, $path);
445                                 }
446                         }
447                         return $path;
448                 } elseif (preg_match_all('/\<embed(\s[^\>])*\ssrc\=[\\\\]?\"([^\\\\^\"]+)[\\\\]?\".*/i', $path, $matches) > 0){
449                         foreach ($matches[2] as $k=>$v){
450                                 if(strpos($v, 'http://')===false && !in_array($v, $this->items)) {
451                                         $this->items[] = $v;    //save the url of this media.
452         //                              $path = str_replace($v, $this->relative_path.$v, $path);
453                                 }
454                         }
455                         return $path;
456                 } else {
457                         return $path;   
458                 }
459         }
460
461
462         //public
463         function close(){
464                 //Free the XML parser
465                 unset($this->response_label);
466                 unset($this->field_label);
467                 unset($this->temp_answer);
468                 xml_parser_free($this->parser);
469         }
470
471 }
472
473 ?>