move code up one directory
[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                                 if (in_array('not', $this->element_path)) {                             
234                                     //if there is a "not", it's a multiple answer, and this should be included to the answer
235                                     break;
236                 }
237                                 $this->temp_answer[$this->attributes[$this->item_num][$name]['respident']]['name'][] = $this->character_data;
238                                 //responses handling, remember to save the answers or match them up
239                                 if (!is_array($this->answers[$this->item_num])){
240                                         $this->answers[$this->item_num] = array();
241                                 }
242                                 array_push($this->answers[$this->item_num], $this->reconstructRelativePath($this->character_data));
243                                 break;
244                         case 'setvar':
245                                 $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['value'][] = $this->character_data;
246                                 $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['attribute'][] = $this->attributes[$this->item_num]['setvar']['varname'];
247                                 break;
248                         case 'respcondition':
249                                 if (empty($this->temp_answer)) {
250                                         break;
251                                 }
252
253                                 //closing this tag means a selection of choices have ended.  Assign the correct answer in this case.
254                                 $tv = $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']];
255                                 //debug($tv, 'harris'.$this->item_num);
256                 //debug($this->choices[$this->item_num], 'choices');
257                                 //debug($this->answers_for_matching[$this->item_num], 'answers for matching');
258
259                                 //If matching, then attribute = 'Respondus_correct'; otherwise it is 'que_score'
260                                 if ($this->getQuestionType($this->item_num) == 5){
261                                         if ($tv['answerAdded']!=true && !empty($tv['attribute'])){
262                                                 foreach ($tv['attribute'] as $att_id => $att_value){
263                                                         //Handles Respondus' (and blakcboard, angels, etc) responses schemas
264                                                         if (strtolower($att_value)=='respondus_correct'){
265                                                                 //Then this is the right answer
266                                                                 if (!is_array($this->answers_for_matching[$this->item_num])){
267                                                                         $this->answers_for_matching[$this->item_num] = array();
268                                                                 }
269                                                                 //The condition here is to check rather the answers have been duplicated, otherwise the indexing won't be right.
270                                                                 //sizeof[answers] != sizeof[questions], then the index matching is wrong.
271                                                                 //Created a problem though, which is then many-to-1 matching fails, cuz answers will be repeated.
272                                                                 //Sep 2,08, Fixed by adding a flag into the array
273         //                                                      if (!in_array($tv['name'][$att_id], $this->answers_for_matching[$this->item_num])){
274                                                                         array_push($this->answers_for_matching[$this->item_num], $tv['name'][$att_id]);
275                                                                         $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['answerAdded'] = true;
276                                                                         
277                                                                         //add mark
278                                                                         $this->weights[$this->item_num] = floatval($tv['value'][$att_id]);
279         //                                                      } 
280                                                                 break;
281                                                         } 
282                                                 }
283                                         }
284                                 } else {
285                                         $pos = sizeof($tv['value']) - 1;        //position of the last entry of the "temp answer's value" array
286                                         //Retrieve the last entry of the "temp answer's value" array
287                                         $current_answer = $tv['value'][$pos];
288                                         if (floatval($current_answer) > 0){
289                                                 if (!is_array($this->answers_for_matching[$this->item_num])){
290                                                         $this->answers_for_matching[$this->item_num] = array();
291                                                 }                                                       
292 //                                                      if (!in_array($tv['name'][$val_id], $this->answers_for_matching[$this->item_num])){
293                                                         array_push($this->answers_for_matching[$this->item_num], $tv['name'][sizeof($tv['name'])-1]);
294                                                         //add mark
295                                                         $this->weights[$this->item_num] += floatval($current_answer);
296 //                                                      } 
297                                         }
298                                 } 
299                                 break;
300                         case 'fieldlabel':
301                                 $this->field_label[$this->item_num] = $this->character_data;
302                                 break;
303                         case 'fieldentry':
304                                 $this->field_entry[$this->item_num][$this->field_label[$this->item_num]] = $this->character_data;
305                                 break;
306                         case 'qmd_itemtype':
307                                 //Deprecated as of QTI 1.2.
308                                 if (empty($this->field_entry[$this->item_num][$name])){
309                                         $this->field_entry[$this->item_num][$name] = $this->character_data;
310                                 } 
311                                 break;
312                         default:
313                                 break;
314                 }
315 //              debug($this->element_path, "Ele Path");
316
317                 //pop stack and reset character data, o/w it will stack up
318                 array_pop($this->element_path);
319                 $this->character_data = '';
320         }
321
322         // private      
323         function characterData($parser, $data){
324                 global $addslashes;
325                 if (trim($data)!=''){
326                         $this->character_data .= $addslashes(preg_replace('/[\t\0\x0B(\r\n)]*/', '', $data));
327 //                      $this->character_data .= trim($data);
328                 }
329         }
330
331         /*
332          * This function returns the question type of this XML.
333          * @access      public 
334          * @param       the item_num
335          * @return  1: m/c
336      *          2: t/f
337      *          3: open ended question
338      *          4: likert
339      *          5: s match
340      *          6: order
341      *          7: m/a
342      *          8: g match
343      *          false for not found.
344          */
345         function getQuestionType($item_num){
346                 switch ($this->field_entry[$item_num]['qmd_questiontype']){
347                         case 'Multiple-choice':
348                                 //1, 4
349                                 //likert have no answers
350                                 if (empty($this->answers)){
351                                         return 4;
352                                 }
353                                 return 1;
354                                 break;
355                         case 'True/false':
356                                 return 2;
357                                 break;
358                         case 'FIB-string':
359                                 return 3;
360                                 break;
361                         case 'Drag-and-drop':
362                                 return 5;
363                                 break;
364                         case 'Multiple-response':
365                                 return 7;
366                                 break;
367                 } 
368
369                 switch ($this->field_entry[$item_num]['qmd_itemtype']){
370                         case 'Matching':
371                                 //matching
372                                 return 5;
373                                 break;
374                 }
375
376                 //handles CC packages
377                 switch ($this->field_entry[$item_num]['cc_profile']){
378                         case 'cc.multiple_choice.v0p1':
379                                 return 1;
380                                 break;
381                         case 'cc.true_false.v0p1':
382                                 return 2;
383                                 break;
384                         case 'cc.fib.v0p1':
385                                 return 3;
386                                 break;
387                         case 'cc.multiple_response.v0p1':
388                                 return 7;
389                                 break;
390                 }
391         
392
393                 //Check if this is an ordering, or matching
394                 $response_obj;
395                 switch ($this->response_type[$item_num]){
396                         case AT_QTI_REPONSE_LID:
397                                 $response_obj = $this->attributes[$item_num]['response_lid'];
398                                 break;
399                         case AT_QTI_REPONSE_GRP:
400                                 $response_obj = $this->attributes[$item_num]['response_grp'];
401                                 break;
402                         case AT_QTI_REPONSE_STR:
403                                 $response_obj = $this->attributes[$item_num]['response_str'];
404                                 return 3;       //no need to parse the rcardinality?
405                                 break;
406                 }
407                 if ($response_obj['rcardinality'] == 'Ordered'){
408                         return 6;
409                 } elseif ($response_obj['rcardinality'] == 'Multiple'){
410                         //TODO Multiple answers, Simple matching and Graphical matching
411                         if (empty($this->field_entry[$item_num])){
412                                 return 7;
413                         }
414                         return 5;
415                 } elseif ($response_obj['rcardinality'] == 'Single'){
416                         return 1; //assume mc
417                 }
418
419                 //None found.
420                 return false;
421         }
422
423
424         //set relative path
425         //must be used before calling parse.  Otherwise it will be null.
426         //private
427         function setRelativePath($path){
428                 if ($path != ''){
429                         if ($path[-1] != '/'){
430                                 $path .= '/'; 
431                         }
432                         $this->relative_path = $path;
433                 }
434         }
435
436
437         //private
438         //when importing, the path of the images are all changed.  Have to parse them out and add the extra path in.
439         //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.  
440         function reconstructRelativePath($path){
441                 //match img tag, all.
442 //              if (preg_match_all('/\<img(\s[^\>])*\ssrc\=[\\\\]?\"([^\\\\^\"]+)[\\\\]?\".*\/?\>/i', $path, $matches) > 0){
443 //fixes multiple image tags within a $path
444                 if (preg_match_all('/\<img(\s[\w^img]+\=[\\\\]?\"[^\\\\^\"]+[\\\\]?\")*\ssrc\=[\\\\]?\"([^\\\\^\"]+)[\\\\]?\"/i', $path, $matches) > 0){
445                         foreach ($matches[2] as $k=>$v){
446                                 if(strpos($v, 'http://')===false && !in_array($v, $this->items)) {
447                                         $this->items[] = $v;    //save the url of this media.
448         //                              $path = str_replace($v, $this->relative_path.$v, $path);
449                                 }
450                         }
451                         return $path;
452                 } elseif (preg_match_all('/\<embed(\s[^\>])*\ssrc\=[\\\\]?\"([^\\\\^\"]+)[\\\\]?\".*/i', $path, $matches) > 0){
453                         foreach ($matches[2] as $k=>$v){
454                                 if(strpos($v, 'http://')===false && !in_array($v, $this->items)) {
455                                         $this->items[] = $v;    //save the url of this media.
456         //                              $path = str_replace($v, $this->relative_path.$v, $path);
457                                 }
458                         }
459                         return $path;
460                 } else {
461                         return $path;   
462                 }
463         }
464
465
466         //public
467         function close(){
468                 //Free the XML parser
469                 unset($this->response_label);
470                 unset($this->field_label);
471                 unset($this->temp_answer);
472                 xml_parser_free($this->parser);
473         }
474
475 }
476
477 ?>