PageRenderTime 51ms CodeModel.GetById 14ms RepoModel.GetById 1ms app.codeStats 0ms

/include/classes/QTI/QTIParser.class.php

https://github.com/supungs/AContent
PHP | 468 lines | 343 code | 34 blank | 91 comment | 54 complexity | 540cbd3e5d159f084bed958bcaa27a76 MD5 | raw file
Possible License(s): LGPL-2.0, LGPL-2.1, MPL-2.0-no-copyleft-exception, MIT, AGPL-1.0
  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. //Constances
  13. define('TR_QTI_REPONSE_GRP', 1);
  14. define('TR_QTI_REPONSE_LID', 2);
  15. define('TR_QTI_REPONSE_STR', 3);
  16. /**
  17. * QTIParser
  18. * Class for parsing XML language info and returning a QTI Object
  19. * @access public
  20. * @author Harris Wong
  21. */
  22. class QTIParser {
  23. // all private
  24. var $parser; // the XML handler
  25. var $qti_type; // QTI specification versoin, imsqti_xmlv1p1, imsqti_item_xmlv2p1, imsqti_xmlv1p2
  26. var $character_data; // tmp variable for storing the data
  27. var $element_path; // array of element paths (basically a stack)
  28. var $title; //title for this question test
  29. var $q_identifiers = array(); //The identifier of the choice. This identifier must not be used by any other choice or item variable.
  30. var $question = ''; //question of this QTI
  31. var $response_type = array(); //detects what type of question this would be.
  32. var $relative_path = ''; //the relative path to all resources in this xml.
  33. //stacks
  34. var $choices = array(); //answers array that keep tracks of all the correct answers
  35. var $groups = array(); //groups for matching, the left handside to match with the different choices
  36. var $attributes = array(); //tag attribute
  37. var $answers = array(); //correct answers
  38. var $response_label = array(); //temporary holders for response labels
  39. var $field_label = array(); //fields label
  40. var $field_entry = array(); //fields entry
  41. var $feedback = array(); //question feedback
  42. var $item_num = 0; //item number
  43. var $items = array(); //stacks of media items, ie. img, embed, ahref etc.
  44. var $qmd_itemtype = -1; //qmd flag
  45. var $temp_answer = array(); //store the temp answer stack
  46. var $answers_for_matching = array();
  47. var $weights = array(); //the weight of each question
  48. //constructor
  49. function QTIParser($qti_type='') {
  50. $this->qti_type = $qti_type;
  51. $this->parser = xml_parser_create();
  52. xml_set_object($this->parser, $this);
  53. xml_parser_set_option($this->parser, XML_OPTION_CASE_FOLDING, false); /* conform to W3C specs */
  54. xml_set_element_handler($this->parser, 'startElement', 'endElement');
  55. xml_set_character_data_handler($this->parser, 'characterData');
  56. }
  57. // public
  58. // @return true if parsed successfully, false otherwise
  59. function parse($xml_data) {
  60. $this->element_path = array();
  61. $this->character_data = '';
  62. xml_parse($this->parser, $xml_data, TRUE);
  63. //Loop thru each item and replace if existed
  64. foreach ($this->answers_for_matching as $afm_k => $afk_v){
  65. if (!empty($this->answers_for_matching[$afm_k])){
  66. $this->answers[$afm_k] = $afk_v;
  67. }
  68. }
  69. if(in_array('questestinterop', $this->element_path) ||
  70. in_array('assessment', $this->element_path)){
  71. //this is a v2.1+ package
  72. return false;
  73. } else {
  74. return true;
  75. }
  76. }
  77. // private
  78. function startElement($parser, $name, $attributes) {
  79. global $msg;
  80. //save attributes.
  81. switch($name) {
  82. case 'section':
  83. $this->title = $attributes['title'];
  84. break;
  85. case 'response_lid':
  86. if ($this->response_type[$this->item_num] <= 0) {
  87. $this->response_type[$this->item_num] = TR_QTI_REPONSE_LID;
  88. }
  89. case 'response_grp':
  90. if ($this->response_type[$this->item_num] <= 0) {
  91. $this->response_type[$this->item_num] = TR_QTI_REPONSE_GRP;
  92. }
  93. case 'response_str':
  94. $this->attributes[$this->item_num][$name]['ident'] = $attributes['ident'];
  95. $this->attributes[$this->item_num][$name]['rcardinality'] = $attributes['rcardinality'];
  96. if ($this->response_type[$this->item_num] <= 0) {
  97. $this->response_type[$this->item_num] = TR_QTI_REPONSE_STR;
  98. }
  99. break;
  100. case 'response_label':
  101. if(!isset($this->choices[$this->item_num][$attributes['ident']])){
  102. if (!is_array($this->response_label[$this->item_num])){
  103. $this->response_label[$this->item_num] = array();
  104. }
  105. array_push($this->response_label[$this->item_num], $attributes['ident']);
  106. }
  107. break;
  108. case 'varequal':
  109. $this->attributes[$this->item_num][$name]['respident'] = $attributes['respident'];
  110. break;
  111. case 'setvar':
  112. $this->attributes[$this->item_num][$name]['varname'] = $attributes['varname'];
  113. break;
  114. case 'render_choice':
  115. $this->attributes[$this->item_num][$name]['shuffle'] = $attributes['shuffle'];
  116. $this->attributes[$this->item_num][$name]['minnumber'] = $attributes['minnumber'];
  117. $this->attributes[$this->item_num][$name]['maxnumber'] = $attributes['maxnumber'];
  118. break;
  119. case 'render_fib':
  120. $rows = intval($attributes['rows']);
  121. $property = 1;
  122. //1,2,3,4 according to tools/tests/create_question_long.php
  123. if ($rows == 1){
  124. $property = 2;
  125. } elseif ($rows > 1 && $rows <= 5){
  126. $property = 3;
  127. } elseif ($rows > 5){
  128. $property = 4;
  129. }
  130. $this->attributes[$this->item_num][$name]['property'] = $property;
  131. break;
  132. case 'matimage':
  133. $this->attributes[$this->item_num][$name]['imagtype'] = $attributes['imagtype'];
  134. $this->attributes[$this->item_num][$name]['uri'] = $attributes['uri'];
  135. break;
  136. case 'mataudio':
  137. $this->attributes[$this->item_num][$name]['audiotype'] = $attributes['audiotype'];
  138. $this->attributes[$this->item_num][$name]['uri'] = $attributes['uri'];
  139. break;
  140. case 'matvideo':
  141. $this->attributes[$this->item_num][$name]['videotype'] = $attributes['videotype'];
  142. $this->attributes[$this->item_num][$name]['uri'] = $attributes['uri'];
  143. break;
  144. case 'matapplet':
  145. $this->attributes[$this->item_num][$name]['uri'] = $attributes['uri'];
  146. $this->attributes[$this->item_num][$name]['width'] = intval($attributes['width']);
  147. $this->attributes[$this->item_num][$name]['height'] = intval($attributes['height']);
  148. break;
  149. case 'setvar':
  150. $this->attributes[$this->item_num][$name]['varname'] = $attributes['varname'];
  151. $this->attributes[$this->item_num][$name]['action'] = $attributes['action'];
  152. break;
  153. case 'itemproc_extension':
  154. if (preg_match('/imsqti_xmlv1p2\/imscc_xmlv1p0(.*)/', $this->qti_type)){
  155. $msg->addError('QTI_WRONG_PACKAGE');
  156. }
  157. break;
  158. }
  159. array_push($this->element_path, $name);
  160. }
  161. // private
  162. /* called when an element ends */
  163. /* removed the current element from the $path */
  164. function endElement($parser, $name) {
  165. global $msg;
  166. //check element path
  167. $current_pos = count($this->element_path) - 1;
  168. $last_element = $this->element_path[$current_pos - 1];
  169. switch($name) {
  170. case 'item':
  171. $this->item_num++;
  172. break;
  173. case 'mattext':
  174. $this->mat_content[$this->item_num] .= $this->reconstructRelativePath($this->character_data);
  175. break;
  176. case 'matimage':
  177. $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'].'" />';
  178. break;
  179. case 'mataudio':
  180. $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>';
  181. break;
  182. case 'matvideo':
  183. if ($this->attributes[$this->item_num][$name]['videotype'] == 'type/swf'){
  184. $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>';
  185. } elseif ($this->attributes[$this->item_num][$name]['videotype'] == 'type/mov'){
  186. $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>';
  187. }
  188. break;
  189. case 'matapplet':
  190. (($this->attributes[$this->item_num][$name]['width'] != 0)? $width = $this->attributes[$this->item_num][$name]['width'] : $width = 460);
  191. (($this->attributes[$this->item_num][$name]['height'] != 0)? $height = $this->attributes[$this->item_num][$name]['height'] : $height = 160);
  192. $this->mat_content[$this->item_num] .= '<applet code="'.$this->attributes[$this->item_num][$name]['uri'].'" width="'.$width.'" height="'.$height.'" alt="Applet not loaded."></applet>';
  193. break;
  194. case 'material':
  195. //check who is mattext's ancestor, started from the most known inner layer
  196. if (in_array('response_label', $this->element_path)){
  197. if(!in_array($this->mat_content, $this->choices)){
  198. //This is one of the choices.
  199. if (!empty($this->response_label[$this->item_num])){
  200. $this->choices[$this->item_num][array_pop($this->response_label[$this->item_num])] = $this->mat_content[$this->item_num];
  201. }
  202. }
  203. } elseif (in_array('response_grp', $this->element_path) || in_array('response_lid', $this->element_path)){
  204. //for matching, where there are groups
  205. //keep in mind that Respondus handles this by using response_lid
  206. $this->groups[$this->item_num][] = $this->reconstructRelativePath($this->mat_content[$this->item_num]);
  207. } elseif (in_array('presentation', $this->element_path)){
  208. $this->question[$this->item_num] = $this->reconstructRelativePath($this->mat_content[$this->item_num]);
  209. } elseif (in_array('itemfeedback', $this->element_path)){
  210. $this->feedback[$this->item_num] = $this->mat_content[$this->item_num];
  211. }
  212. //once material is closed, reset the mat_content variable.
  213. $this->mat_content[$this->item_num] = '';
  214. break;
  215. case 'varequal':
  216. //stores the answers (either correct or incorrect) into a stack
  217. $this->temp_answer[$this->attributes[$this->item_num][$name]['respident']]['name'][] = $this->character_data;
  218. //responses handling, remember to save the answers or match them up
  219. if (!is_array($this->answers[$this->item_num])){
  220. $this->answers[$this->item_num] = array();
  221. }
  222. array_push($this->answers[$this->item_num], $this->reconstructRelativePath($this->character_data));
  223. break;
  224. case 'setvar':
  225. $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['value'][] = $this->character_data;
  226. $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['attribute'][] = $this->attributes[$this->item_num]['setvar']['varname'];
  227. break;
  228. case 'respcondition':
  229. if (empty($this->temp_answer)) {
  230. break;
  231. }
  232. //closing this tag means a selection of choices have ended. Assign the correct answer in this case.
  233. $tv = $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']];
  234. //If matching, then attribute = 'Respondus_correct'; otherwise it is 'que_score'
  235. if ($this->getQuestionType($this->item_num) == 5){
  236. if ($tv['answerAdded']!=true && !empty($tv['attribute'])){
  237. foreach ($tv['attribute'] as $att_id => $att_value){
  238. //Handles Respondus' (and blakcboard, angels, etc) responses schemas
  239. if (strtolower($att_value)=='respondus_correct'){
  240. //Then this is the right answer
  241. if (!is_array($this->answers_for_matching[$this->item_num])){
  242. $this->answers_for_matching[$this->item_num] = array();
  243. }
  244. //The condition here is to check rather the answers have been duplicated, otherwise the indexing won't be right.
  245. //sizeof[answers] != sizeof[questions], then the index matching is wrong.
  246. //Created a problem though, which is then many-to-1 matching fails, cuz answers will be repeated.
  247. //Sep 2,08, Fixed by adding a flag into the array
  248. // if (!in_array($tv['name'][$att_id], $this->answers_for_matching[$this->item_num])){
  249. array_push($this->answers_for_matching[$this->item_num], $tv['name'][$att_id]);
  250. $this->temp_answer[$this->attributes[$this->item_num]['varequal']['respident']]['answerAdded'] = true;
  251. //add mark
  252. $this->weights[$this->item_num] = floatval($tv['value'][$att_id]);
  253. // }
  254. break;
  255. }
  256. }
  257. }
  258. } else {
  259. $pos = sizeof($tv['value']) - 1; //position of the last entry of the "temp answer's value" array
  260. //Retrieve the last entry of the "temp answer's value" array
  261. $current_answer = $tv['value'][$pos];
  262. if (floatval($current_answer) > 0){
  263. if (!is_array($this->answers_for_matching[$this->item_num])){
  264. $this->answers_for_matching[$this->item_num] = array();
  265. }
  266. // if (!in_array($tv['name'][$val_id], $this->answers_for_matching[$this->item_num])){
  267. array_push($this->answers_for_matching[$this->item_num], $tv['name'][sizeof($tv['name'])-1]);
  268. //add mark
  269. $this->weights[$this->item_num] += floatval($current_answer);
  270. // }
  271. }
  272. }
  273. break;
  274. case 'fieldlabel':
  275. $this->field_label[$this->item_num] = $this->character_data;
  276. break;
  277. case 'fieldentry':
  278. $this->field_entry[$this->item_num][$this->field_label[$this->item_num]] = $this->character_data;
  279. break;
  280. case 'qmd_itemtype':
  281. //Deprecated as of QTI 1.2.
  282. if (empty($this->field_entry[$this->item_num][$name])){
  283. $this->field_entry[$this->item_num][$name] = $this->character_data;
  284. }
  285. break;
  286. default:
  287. break;
  288. }
  289. //pop stack and reset character data, o/w it will stack up
  290. array_pop($this->element_path);
  291. $this->character_data = '';
  292. }
  293. // private
  294. function characterData($parser, $data){
  295. global $addslashes;
  296. if (trim($data)!=''){
  297. $this->character_data .= $addslashes(preg_replace('/[\t\0\x0B(\r\n)]*/', '', $data));
  298. // $this->character_data .= trim($data);
  299. }
  300. }
  301. /*
  302. * This function returns the question type of this XML.
  303. * @access public
  304. * @param the item_num
  305. * @return 1: m/c
  306. * 2: t/f
  307. * 3: open ended question
  308. * 4: likert
  309. * 5: s match
  310. * 6: order
  311. * 7: m/a
  312. * 8: g match
  313. * false for not found.
  314. */
  315. function getQuestionType($item_num){
  316. switch ($this->field_entry[$item_num]['qmd_questiontype']){
  317. case 'Multiple-choice':
  318. //1, 4
  319. //likert have no answers
  320. if (empty($this->answers)){
  321. return 4;
  322. }
  323. return 1;
  324. break;
  325. case 'True/false':
  326. return 2;
  327. break;
  328. case 'FIB-string':
  329. return 3;
  330. break;
  331. case 'Drag-and-drop':
  332. return 5;
  333. break;
  334. case 'Multiple-response':
  335. return 7;
  336. break;
  337. }
  338. switch ($this->field_entry[$item_num]['qmd_itemtype']){
  339. case 'Matching':
  340. //matching
  341. return 5;
  342. break;
  343. }
  344. //handles CC packages
  345. switch ($this->field_entry[$item_num]['cc_profile']){
  346. case 'cc.multiple_choice.v0p1':
  347. return 1;
  348. break;
  349. case 'cc.true_false.v0p1':
  350. return 2;
  351. break;
  352. case 'cc.fib.v0p1':
  353. return 3;
  354. break;
  355. case 'cc.multiple_response.v0p1':
  356. return 7;
  357. break;
  358. }
  359. //Check if this is an ordering, or matching
  360. $response_obj;
  361. switch ($this->response_type[$item_num]){
  362. case TR_QTI_REPONSE_LID:
  363. $response_obj = $this->attributes[$item_num]['response_lid'];
  364. break;
  365. case TR_QTI_REPONSE_GRP:
  366. $response_obj = $this->attributes[$item_num]['response_grp'];
  367. break;
  368. case TR_QTI_REPONSE_STR:
  369. $response_obj = $this->attributes[$item_num]['response_str'];
  370. return 3; //no need to parse the rcardinality?
  371. break;
  372. }
  373. if ($response_obj['rcardinality'] == 'Ordered'){
  374. return 6;
  375. } elseif ($response_obj['rcardinality'] == 'Multiple'){
  376. //TODO Multiple answers, Simple matching and Graphical matching
  377. if (empty($this->field_entry[$item_num])){
  378. return 7;
  379. }
  380. return 5;
  381. } elseif ($response_obj['rcardinality'] == 'Single'){
  382. return 1; //assume mc
  383. }
  384. //None found.
  385. return false;
  386. }
  387. //set relative path
  388. //must be used before calling parse. Otherwise it will be null.
  389. //private
  390. function setRelativePath($path){
  391. if ($path != ''){
  392. if ($path[-1] != '/'){
  393. $path .= '/';
  394. }
  395. $this->relative_path = $path;
  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. //public
  426. function close(){
  427. //Free the XML parser
  428. unset($this->response_label);
  429. unset($this->field_label);
  430. unset($this->temp_answer);
  431. xml_parser_free($this->parser);
  432. }
  433. }
  434. ?>