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