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