DirectLex.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. <?php
  2. /**
  3. * Our in-house implementation of a parser.
  4. *
  5. * A pure PHP parser, DirectLex has absolutely no dependencies, making
  6. * it a reasonably good default for PHP4. Written with efficiency in mind,
  7. * it can be four times faster than HTMLPurifier_Lexer_PEARSax3, although it
  8. * pales in comparison to HTMLPurifier_Lexer_DOMLex.
  9. *
  10. * @todo Reread XML spec and document differences.
  11. */
  12. class HTMLPurifier_Lexer_DirectLex extends HTMLPurifier_Lexer
  13. {
  14. public $tracksLineNumbers = true;
  15. /**
  16. * Whitespace characters for str(c)spn.
  17. */
  18. protected $_whitespace = "\x20\x09\x0D\x0A";
  19. /**
  20. * Callback function for script CDATA fudge
  21. * @param $matches, in form of array(opening tag, contents, closing tag)
  22. */
  23. protected function scriptCallback($matches) {
  24. return $matches[1] . htmlspecialchars($matches[2], ENT_COMPAT, 'UTF-8') . $matches[3];
  25. }
  26. public function tokenizeHTML($html, $config, $context) {
  27. // special normalization for script tags without any armor
  28. // our "armor" heurstic is a < sign any number of whitespaces after
  29. // the first script tag
  30. if ($config->get('HTML.Trusted')) {
  31. $html = preg_replace_callback('#(<script[^>]*>)(\s*[^<].+?)(</script>)#si',
  32. array($this, 'scriptCallback'), $html);
  33. }
  34. $html = $this->normalize($html, $config, $context);
  35. $cursor = 0; // our location in the text
  36. $inside_tag = false; // whether or not we're parsing the inside of a tag
  37. $array = array(); // result array
  38. // This is also treated to mean maintain *column* numbers too
  39. $maintain_line_numbers = $config->get('Core.MaintainLineNumbers');
  40. if ($maintain_line_numbers === null) {
  41. // automatically determine line numbering by checking
  42. // if error collection is on
  43. $maintain_line_numbers = $config->get('Core.CollectErrors');
  44. }
  45. if ($maintain_line_numbers) {
  46. $current_line = 1;
  47. $current_col = 0;
  48. $length = strlen($html);
  49. } else {
  50. $current_line = false;
  51. $current_col = false;
  52. $length = false;
  53. }
  54. $context->register('CurrentLine', $current_line);
  55. $context->register('CurrentCol', $current_col);
  56. $nl = "\n";
  57. // how often to manually recalculate. This will ALWAYS be right,
  58. // but it's pretty wasteful. Set to 0 to turn off
  59. $synchronize_interval = $config->get('Core.DirectLexLineNumberSyncInterval');
  60. $e = false;
  61. if ($config->get('Core.CollectErrors')) {
  62. $e =& $context->get('ErrorCollector');
  63. }
  64. // for testing synchronization
  65. $loops = 0;
  66. while(++$loops) {
  67. // $cursor is either at the start of a token, or inside of
  68. // a tag (i.e. there was a < immediately before it), as indicated
  69. // by $inside_tag
  70. if ($maintain_line_numbers) {
  71. // $rcursor, however, is always at the start of a token.
  72. $rcursor = $cursor - (int) $inside_tag;
  73. // Column number is cheap, so we calculate it every round.
  74. // We're interested at the *end* of the newline string, so
  75. // we need to add strlen($nl) == 1 to $nl_pos before subtracting it
  76. // from our "rcursor" position.
  77. $nl_pos = strrpos($html, $nl, $rcursor - $length);
  78. $current_col = $rcursor - (is_bool($nl_pos) ? 0 : $nl_pos + 1);
  79. // recalculate lines
  80. if (
  81. $synchronize_interval && // synchronization is on
  82. $cursor > 0 && // cursor is further than zero
  83. $loops % $synchronize_interval === 0 // time to synchronize!
  84. ) {
  85. $current_line = 1 + $this->substrCount($html, $nl, 0, $cursor);
  86. }
  87. }
  88. $position_next_lt = strpos($html, '<', $cursor);
  89. $position_next_gt = strpos($html, '>', $cursor);
  90. // triggers on "<b>asdf</b>" but not "asdf <b></b>"
  91. // special case to set up context
  92. if ($position_next_lt === $cursor) {
  93. $inside_tag = true;
  94. $cursor++;
  95. }
  96. if (!$inside_tag && $position_next_lt !== false) {
  97. // We are not inside tag and there still is another tag to parse
  98. $token = new
  99. HTMLPurifier_Token_Text(
  100. $this->parseData(
  101. substr(
  102. $html, $cursor, $position_next_lt - $cursor
  103. )
  104. )
  105. );
  106. if ($maintain_line_numbers) {
  107. $token->rawPosition($current_line, $current_col);
  108. $current_line += $this->substrCount($html, $nl, $cursor, $position_next_lt - $cursor);
  109. }
  110. $array[] = $token;
  111. $cursor = $position_next_lt + 1;
  112. $inside_tag = true;
  113. continue;
  114. } elseif (!$inside_tag) {
  115. // We are not inside tag but there are no more tags
  116. // If we're already at the end, break
  117. if ($cursor === strlen($html)) break;
  118. // Create Text of rest of string
  119. $token = new
  120. HTMLPurifier_Token_Text(
  121. $this->parseData(
  122. substr(
  123. $html, $cursor
  124. )
  125. )
  126. );
  127. if ($maintain_line_numbers) $token->rawPosition($current_line, $current_col);
  128. $array[] = $token;
  129. break;
  130. } elseif ($inside_tag && $position_next_gt !== false) {
  131. // We are in tag and it is well formed
  132. // Grab the internals of the tag
  133. $strlen_segment = $position_next_gt - $cursor;
  134. if ($strlen_segment < 1) {
  135. // there's nothing to process!
  136. $token = new HTMLPurifier_Token_Text('<');
  137. $cursor++;
  138. continue;
  139. }
  140. $segment = substr($html, $cursor, $strlen_segment);
  141. if ($segment === false) {
  142. // somehow, we attempted to access beyond the end of
  143. // the string, defense-in-depth, reported by Nate Abele
  144. break;
  145. }
  146. // Check if it's a comment
  147. if (
  148. substr($segment, 0, 3) === '!--'
  149. ) {
  150. // re-determine segment length, looking for -->
  151. $position_comment_end = strpos($html, '-->', $cursor);
  152. if ($position_comment_end === false) {
  153. // uh oh, we have a comment that extends to
  154. // infinity. Can't be helped: set comment
  155. // end position to end of string
  156. if ($e) $e->send(E_WARNING, 'Lexer: Unclosed comment');
  157. $position_comment_end = strlen($html);
  158. $end = true;
  159. } else {
  160. $end = false;
  161. }
  162. $strlen_segment = $position_comment_end - $cursor;
  163. $segment = substr($html, $cursor, $strlen_segment);
  164. $token = new
  165. HTMLPurifier_Token_Comment(
  166. substr(
  167. $segment, 3, $strlen_segment - 3
  168. )
  169. );
  170. if ($maintain_line_numbers) {
  171. $token->rawPosition($current_line, $current_col);
  172. $current_line += $this->substrCount($html, $nl, $cursor, $strlen_segment);
  173. }
  174. $array[] = $token;
  175. $cursor = $end ? $position_comment_end : $position_comment_end + 3;
  176. $inside_tag = false;
  177. continue;
  178. }
  179. // Check if it's an end tag
  180. $is_end_tag = (strpos($segment,'/') === 0);
  181. if ($is_end_tag) {
  182. $type = substr($segment, 1);
  183. $token = new HTMLPurifier_Token_End($type);
  184. if ($maintain_line_numbers) {
  185. $token->rawPosition($current_line, $current_col);
  186. $current_line += $this->substrCount($html, $nl, $cursor, $position_next_gt - $cursor);
  187. }
  188. $array[] = $token;
  189. $inside_tag = false;
  190. $cursor = $position_next_gt + 1;
  191. continue;
  192. }
  193. // Check leading character is alnum, if not, we may
  194. // have accidently grabbed an emoticon. Translate into
  195. // text and go our merry way
  196. if (!ctype_alpha($segment[0])) {
  197. // XML: $segment[0] !== '_' && $segment[0] !== ':'
  198. if ($e) $e->send(E_NOTICE, 'Lexer: Unescaped lt');
  199. $token = new HTMLPurifier_Token_Text('<');
  200. if ($maintain_line_numbers) {
  201. $token->rawPosition($current_line, $current_col);
  202. $current_line += $this->substrCount($html, $nl, $cursor, $position_next_gt - $cursor);
  203. }
  204. $array[] = $token;
  205. $inside_tag = false;
  206. continue;
  207. }
  208. // Check if it is explicitly self closing, if so, remove
  209. // trailing slash. Remember, we could have a tag like <br>, so
  210. // any later token processing scripts must convert improperly
  211. // classified EmptyTags from StartTags.
  212. $is_self_closing = (strrpos($segment,'/') === $strlen_segment-1);
  213. if ($is_self_closing) {
  214. $strlen_segment--;
  215. $segment = substr($segment, 0, $strlen_segment);
  216. }
  217. // Check if there are any attributes
  218. $position_first_space = strcspn($segment, $this->_whitespace);
  219. if ($position_first_space >= $strlen_segment) {
  220. if ($is_self_closing) {
  221. $token = new HTMLPurifier_Token_Empty($segment);
  222. } else {
  223. $token = new HTMLPurifier_Token_Start($segment);
  224. }
  225. if ($maintain_line_numbers) {
  226. $token->rawPosition($current_line, $current_col);
  227. $current_line += $this->substrCount($html, $nl, $cursor, $position_next_gt - $cursor);
  228. }
  229. $array[] = $token;
  230. $inside_tag = false;
  231. $cursor = $position_next_gt + 1;
  232. continue;
  233. }
  234. // Grab out all the data
  235. $type = substr($segment, 0, $position_first_space);
  236. $attribute_string =
  237. trim(
  238. substr(
  239. $segment, $position_first_space
  240. )
  241. );
  242. if ($attribute_string) {
  243. $attr = $this->parseAttributeString(
  244. $attribute_string
  245. , $config, $context
  246. );
  247. } else {
  248. $attr = array();
  249. }
  250. if ($is_self_closing) {
  251. $token = new HTMLPurifier_Token_Empty($type, $attr);
  252. } else {
  253. $token = new HTMLPurifier_Token_Start($type, $attr);
  254. }
  255. if ($maintain_line_numbers) {
  256. $token->rawPosition($current_line, $current_col);
  257. $current_line += $this->substrCount($html, $nl, $cursor, $position_next_gt - $cursor);
  258. }
  259. $array[] = $token;
  260. $cursor = $position_next_gt + 1;
  261. $inside_tag = false;
  262. continue;
  263. } else {
  264. // inside tag, but there's no ending > sign
  265. if ($e) $e->send(E_WARNING, 'Lexer: Missing gt');
  266. $token = new
  267. HTMLPurifier_Token_Text(
  268. '<' .
  269. $this->parseData(
  270. substr($html, $cursor)
  271. )
  272. );
  273. if ($maintain_line_numbers) $token->rawPosition($current_line, $current_col);
  274. // no cursor scroll? Hmm...
  275. $array[] = $token;
  276. break;
  277. }
  278. break;
  279. }
  280. $context->destroy('CurrentLine');
  281. $context->destroy('CurrentCol');
  282. return $array;
  283. }
  284. /**
  285. * PHP 5.0.x compatible substr_count that implements offset and length
  286. */
  287. protected function substrCount($haystack, $needle, $offset, $length) {
  288. static $oldVersion;
  289. if ($oldVersion === null) {
  290. $oldVersion = version_compare(PHP_VERSION, '5.1', '<');
  291. }
  292. if ($oldVersion) {
  293. $haystack = substr($haystack, $offset, $length);
  294. return substr_count($haystack, $needle);
  295. } else {
  296. return substr_count($haystack, $needle, $offset, $length);
  297. }
  298. }
  299. /**
  300. * Takes the inside of an HTML tag and makes an assoc array of attributes.
  301. *
  302. * @param $string Inside of tag excluding name.
  303. * @returns Assoc array of attributes.
  304. */
  305. public function parseAttributeString($string, $config, $context) {
  306. $string = (string) $string; // quick typecast
  307. if ($string == '') return array(); // no attributes
  308. $e = false;
  309. if ($config->get('Core.CollectErrors')) {
  310. $e =& $context->get('ErrorCollector');
  311. }
  312. // let's see if we can abort as quickly as possible
  313. // one equal sign, no spaces => one attribute
  314. $num_equal = substr_count($string, '=');
  315. $has_space = strpos($string, ' ');
  316. if ($num_equal === 0 && !$has_space) {
  317. // bool attribute
  318. return array($string => $string);
  319. } elseif ($num_equal === 1 && !$has_space) {
  320. // only one attribute
  321. list($key, $quoted_value) = explode('=', $string);
  322. $quoted_value = trim($quoted_value);
  323. if (!$key) {
  324. if ($e) $e->send(E_ERROR, 'Lexer: Missing attribute key');
  325. return array();
  326. }
  327. if (!$quoted_value) return array($key => '');
  328. $first_char = @$quoted_value[0];
  329. $last_char = @$quoted_value[strlen($quoted_value)-1];
  330. $same_quote = ($first_char == $last_char);
  331. $open_quote = ($first_char == '"' || $first_char == "'");
  332. if ( $same_quote && $open_quote) {
  333. // well behaved
  334. $value = substr($quoted_value, 1, strlen($quoted_value) - 2);
  335. } else {
  336. // not well behaved
  337. if ($open_quote) {
  338. if ($e) $e->send(E_ERROR, 'Lexer: Missing end quote');
  339. $value = substr($quoted_value, 1);
  340. } else {
  341. $value = $quoted_value;
  342. }
  343. }
  344. if ($value === false) $value = '';
  345. return array($key => $this->parseData($value));
  346. }
  347. // setup loop environment
  348. $array = array(); // return assoc array of attributes
  349. $cursor = 0; // current position in string (moves forward)
  350. $size = strlen($string); // size of the string (stays the same)
  351. // if we have unquoted attributes, the parser expects a terminating
  352. // space, so let's guarantee that there's always a terminating space.
  353. $string .= ' ';
  354. while(true) {
  355. if ($cursor >= $size) {
  356. break;
  357. }
  358. $cursor += ($value = strspn($string, $this->_whitespace, $cursor));
  359. // grab the key
  360. $key_begin = $cursor; //we're currently at the start of the key
  361. // scroll past all characters that are the key (not whitespace or =)
  362. $cursor += strcspn($string, $this->_whitespace . '=', $cursor);
  363. $key_end = $cursor; // now at the end of the key
  364. $key = substr($string, $key_begin, $key_end - $key_begin);
  365. if (!$key) {
  366. if ($e) $e->send(E_ERROR, 'Lexer: Missing attribute key');
  367. $cursor += strcspn($string, $this->_whitespace, $cursor + 1); // prevent infinite loop
  368. continue; // empty key
  369. }
  370. // scroll past all whitespace
  371. $cursor += strspn($string, $this->_whitespace, $cursor);
  372. if ($cursor >= $size) {
  373. $array[$key] = $key;
  374. break;
  375. }
  376. // if the next character is an equal sign, we've got a regular
  377. // pair, otherwise, it's a bool attribute
  378. $first_char = @$string[$cursor];
  379. if ($first_char == '=') {
  380. // key="value"
  381. $cursor++;
  382. $cursor += strspn($string, $this->_whitespace, $cursor);
  383. if ($cursor === false) {
  384. $array[$key] = '';
  385. break;
  386. }
  387. // we might be in front of a quote right now
  388. $char = @$string[$cursor];
  389. if ($char == '"' || $char == "'") {
  390. // it's quoted, end bound is $char
  391. $cursor++;
  392. $value_begin = $cursor;
  393. $cursor = strpos($string, $char, $cursor);
  394. $value_end = $cursor;
  395. } else {
  396. // it's not quoted, end bound is whitespace
  397. $value_begin = $cursor;
  398. $cursor += strcspn($string, $this->_whitespace, $cursor);
  399. $value_end = $cursor;
  400. }
  401. // we reached a premature end
  402. if ($cursor === false) {
  403. $cursor = $size;
  404. $value_end = $cursor;
  405. }
  406. $value = substr($string, $value_begin, $value_end - $value_begin);
  407. if ($value === false) $value = '';
  408. $array[$key] = $this->parseData($value);
  409. $cursor++;
  410. } else {
  411. // boolattr
  412. if ($key !== '') {
  413. $array[$key] = $key;
  414. } else {
  415. // purely theoretical
  416. if ($e) $e->send(E_ERROR, 'Lexer: Missing attribute key');
  417. }
  418. }
  419. }
  420. return $array;
  421. }
  422. }
  423. // vim: et sw=4 sts=4