footnotes.module 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. <?php
  2. /**
  3. * @file
  4. * The Footnotes module is a filter that can be used to insert
  5. * automatically numbered footnotes into Drupal texts.
  6. */
  7. /**
  8. * Implementation of hook_help().
  9. */
  10. function footnotes_help($path, $arg) {
  11. switch ($path) {
  12. case 'admin/help#footnotes':
  13. // This description is shown in the listing at admin/modules.
  14. return t('Insert automatically numbered footnotes using &lt;fn&gt; or [fn] tags. Enable the footnotes text filter <a href="@url">here</a>.', array('@url' => url('admin/config/content/formats')));
  15. }
  16. }
  17. /**
  18. * Implementation of hook_filter_info().
  19. */
  20. function footnotes_filter_info() {
  21. $filters['filter_footnotes'] = array(
  22. 'title' => t('Footnotes'),
  23. 'description' => t('Insert automatically numbered footnotes using &lt;fn&gt; or [fn] tags.'),
  24. 'process callback' => '_footnotes_filter',
  25. 'settings callback' => '_footnotes_settings',
  26. 'default settings' => array(
  27. 'footnotes_collapse' => 0,
  28. ),
  29. 'tips callback' => '_footnotes_filter_tips',
  30. 'weight' => -20,
  31. );
  32. return $filters;
  33. }
  34. /**
  35. * Short tips are provided on the content editing screen, while
  36. * long tips are provided on a separate linked page. Short tips are optional,
  37. * but long tips are highly recommended.
  38. */
  39. function _footnotes_filter_tips($filter, $format, $long = FALSE) {
  40. if ($long) {
  41. return t('You can insert footnotes directly into texts with <code>[fn]This text becomes a footnote.[/fn]</code>. This will be replaced with a running number (the footnote reference) and the text within the [fn] tags will be moved to the bottom of the page (the footnote). See %link for additional usage options.', array('%link'=>'<a href="http://drupal.org/project/footnotes">' . t('Footnotes Readme page') . '</a>'));
  42. }
  43. else {
  44. return t('Use [fn]...[/fn] (or &lt;fn&gt;...&lt;/fn&gt;) to insert automatically numbered footnotes.');
  45. }
  46. }
  47. /**
  48. * Options for the Footnotes filter.
  49. *
  50. * This has currently 1 setting, the feature to collapse together footnotes
  51. * with identical content is an option.
  52. */
  53. function _footnotes_settings($form, &$form_state, $filter, $format, $defaults, $filters) {
  54. $settings['footnotes_collapse'] = array(
  55. '#type' => 'checkbox',
  56. '#title' => t('Collapse footnotes with identical content'),
  57. '#default_value' => isset($filter->settings['footnotes_collapse']) ? $filter->settings['footnotes_collapse'] : $defaults['footnotes_collapse'],
  58. '#description' => t('If two footnotes have the exact same content, they will be collapsed into one as if using the same value="" attribute.')
  59. );
  60. return $settings;
  61. }
  62. /**
  63. * The bulk of filtering work is done here.
  64. */
  65. function _footnotes_filter($text = '', $filter, $format) {
  66. // Supporting both [fn] and <fn> now. Thanks to fletchgqc http://drupal.org/node/268026
  67. // Convert all square brackets to angle brackets. This way all further code just
  68. // manipulates angle brackets. (Angle brackets are preferred here for the simple reason
  69. // that square brackets are more tedious to use in regexps.)
  70. $text = preg_replace( '|\[fn([^\]]*)\]|', '<fn$1>', $text);
  71. $text = preg_replace( '|\[/fn\]|', '</fn>', $text);
  72. $text = preg_replace( '|\[footnotes([^\]]*)\]|', '<footnotes$1>', $text);
  73. // Check that there are an even number of open and closing tags.
  74. // If there is one closing tag missing, append this to the end.
  75. // If there is more disparity, throw a warning and continue.
  76. // A closing tag may sometimes be missing when we are processing a teaser
  77. // and it has been cut in the middle of the footnote.
  78. // See http://drupal.org/node/253326
  79. $foo = array();
  80. $open_tags = preg_match_all("|<fn([^>]*)>|", $text, $foo);
  81. $close_tags = preg_match_all("|</fn>|", $text, $foo);
  82. if ($open_tags == $close_tags + 1) {
  83. $text = $text . '</fn>';
  84. }
  85. elseif ($open_tags > $close_tags + 1) {
  86. trigger_error(t("You have unclosed fn tags. This is invalid and will produce unpredictable results."));
  87. }
  88. // Before doing the replacement, the callback function needs to know which options to use.
  89. _footnotes_replace_callback($filter->settings['footnotes_collapse'], 'prepare');
  90. $pattern = '|<fn([^>]*)>(.*?)</fn>|s';
  91. $text = preg_replace_callback($pattern , '_footnotes_replace_callback', $text);
  92. // Replace tag <footnotes> with the list of footnotes.
  93. // If tag is not present, by default add the footnotes at the end.
  94. // Thanks to acp on drupal.org for this idea. see http://drupal.org/node/87226
  95. $footer = '';
  96. $footer = _footnotes_replace_callback(NULL, 'output footer');
  97. $pattern = '|(<footnotes([^\]]*)>)|';
  98. if (preg_match($pattern, $text) > 0) {
  99. $text = preg_replace($pattern, $footer, $text, 1);
  100. return $text;
  101. }
  102. else {
  103. return $text . "\n\n" . $footer;
  104. }
  105. }
  106. /**
  107. * Search the $store_matches array for footnote text that matches and return the value.
  108. *
  109. * Note: This does a linear search on the $store_matches array. For a large list of
  110. * footnotes it would be more efficient to maintain a separate array with the footnote
  111. * content as key, in order to do a hash lookup at this stage. Since you typically
  112. * only have a handful of footnotes, this simple search is assumed to be more efficient.
  113. * (but was not tested).
  114. *
  115. * @author djdevin (see http://drupal.org/node/808214)
  116. *
  117. * @param string The footnote text
  118. * @param array The matches array
  119. *
  120. * @return mixed The value of the existing footnote, FALSE otherwise
  121. */
  122. function _footnotes_helper_find_footnote($text, &$store_matches) {
  123. if (!empty($store_matches)) {
  124. foreach ($store_matches as &$fn) {
  125. if ($fn['text'] == $text) {
  126. return $fn['value'];
  127. }
  128. }
  129. }
  130. return FALSE;
  131. }
  132. /**
  133. * Helper function called from preg_replace_callback() above
  134. *
  135. * Uses static vars to temporarily store footnotes found.
  136. * This is not threadsafe, but PHP isn't.
  137. */
  138. function _footnotes_replace_callback( $matches, $op = '' ) {
  139. static $opt_collapse = 0;
  140. static $n = 0;
  141. static $store_matches = array();
  142. static $used_values = array();
  143. $str = '';
  144. if ($op == 'prepare') {
  145. // In the 'prepare' case, the first argument contains the options to use.
  146. // The name 'matches' is incorrect, we just use the variable anyway.
  147. $opt_collapse = $matches;
  148. return 0;
  149. }
  150. if( $op == 'output footer' ) {
  151. if (count($store_matches) > 0) {
  152. // Only if there are stored fn matches, pass the array of fns to be themed
  153. // as a list
  154. // Drupal 7 requires we use "render element" which just introduces a wrapper
  155. // around the old array.
  156. $str = theme('footnote_list',array('footnotes' => $store_matches));
  157. }
  158. // Reset the static variables so they can be used again next time
  159. $n = 0;
  160. $store_matches = array();
  161. $used_values = array();
  162. return $str;
  163. }
  164. // Default op: act as called by preg_replace_callback()
  165. // Random string used to ensure footnote id's are unique, even
  166. // when contents of multiple nodes reside on same page. (fixes http://drupal.org/node/194558)
  167. $randstr = _footnotes_helper_randstr();
  168. $value = '';
  169. // Did the pattern match anything in the <fn> tag?
  170. if ($matches[1]) {
  171. // See if value attribute can parsed, either well-formed in quotes eg <fn value="3">
  172. if (preg_match('|value=["\'](.*?)["\']|',$matches[1],$value_match)) {
  173. $value = $value_match[1];
  174. // Or without quotes eg <fn value=8>
  175. } elseif (preg_match('|value=(\S*)|',$matches[1],$value_match)) {
  176. $value = $value_match[1];
  177. }
  178. }
  179. if ($value) {
  180. // A value label was found. If it is numeric, record it in $n so further notes
  181. // can increment from there.
  182. // After adding support for multiple references to same footnote in the body (http://drupal.org/node/636808)
  183. // also must check that $n is monotonously increasing
  184. if ( is_numeric($value) && $n < $value ) {
  185. $n = $value;
  186. }
  187. } elseif ($opt_collapse and $value_existing = _footnotes_helper_find_footnote($matches[2],$store_matches)) {
  188. // An identical footnote already exists. Set value to the previously existing value.
  189. $value = $value_existing;
  190. } else {
  191. // No value label, either a plain <fn> or unparsable attributes. Increment the
  192. // footnote counter, set label equal to it.
  193. $n++;
  194. $value = $n;
  195. }
  196. // Remove illegal characters from $value so it can be used as an HTML id attribute.
  197. $value_id = preg_replace('|[^\w\-]|', '', $value);
  198. // Create a sanitized version of $text that is suitable for using as HTML attribute
  199. // value. (In particular, as the title attribute to the footnote link.)
  200. $allowed_tags = array();
  201. $text_clean = filter_xss($matches['2'], $allowed_tags);
  202. // HTML attribute cannot contain quotes
  203. $text_clean = str_replace('"', "&quot;", $text_clean);
  204. // Remove newlines. Browsers don't support them anyway and they'll confuse line break converter in filter.module
  205. $text_clean = str_replace("\n", " ", $text_clean);
  206. $text_clean = str_replace("\r", "", $text_clean);
  207. // Create a footnote item as an array.
  208. $fn = array(
  209. 'value' => $value,
  210. 'text' => $matches[2],
  211. 'text_clean' => $text_clean,
  212. 'fn_id' => 'footnote' . $value_id . '_' . $randstr,
  213. 'ref_id' => 'footnoteref' . $value_id . '_' . $randstr
  214. );
  215. // We now allow to repeat the footnote value label, in which case the link to the previously
  216. // existing footnote is returned. Content of the current footnote is ignored.
  217. // See http://drupal.org/node/636808
  218. if( ! in_array( $value, $used_values ) )
  219. {
  220. // This is the normal case, add the footnote to $store_matches
  221. // Store the footnote item.
  222. array_push( $store_matches, $fn );
  223. array_push( $used_values, $value );
  224. }
  225. else
  226. {
  227. // A footnote with the same label already exists
  228. // Use the text and id from the first footnote with this value.
  229. // Any text in this footnote is discarded.
  230. $i = array_search( $value, $used_values );
  231. $fn['text'] = $store_matches[$i]['text'];
  232. $fn['text_clean'] = $store_matches[$i]['text_clean'];
  233. $fn['fn_id'] = $store_matches[$i]['fn_id'];
  234. // Push the new ref_id into the first occurence of this footnote label
  235. // The stored footnote thus holds a list of ref_id's rather than just one id
  236. $ref_array = is_array($store_matches[$i]['ref_id']) ? $store_matches[$i]['ref_id'] : array( $store_matches[$i]['ref_id'] );
  237. array_push( $ref_array, $fn['ref_id'] );
  238. $store_matches[$i]['ref_id'] = $ref_array;
  239. }
  240. // Return the item themed into a footnote link.
  241. // Drupal 7 requires we use "render element" which just introduces a wrapper
  242. // around the old array.
  243. $fn = array('fn' => $fn );
  244. return theme('footnote_link',$fn);
  245. }
  246. /**
  247. * Helper function to return a random text string
  248. *
  249. * @return random (lowercase) alphanumeric string
  250. */
  251. function _footnotes_helper_randstr() {
  252. $chars = "abcdefghijklmnopqrstuwxyz1234567890";
  253. $str = "";
  254. //seeding with srand() not neccessary in modern PHP versions
  255. for( $i = 0; $i < 7; $i++ ) {
  256. $n = rand( 0, strlen( $chars ) - 1 );
  257. $str .= substr( $chars, $n, 1 );
  258. }
  259. return $str;
  260. }
  261. /**
  262. * Implementation of hook_theme()
  263. *
  264. * Thanks to emfabric for this implementation. http://drupal.org/node/221156
  265. */
  266. function footnotes_theme() {
  267. return array(
  268. 'footnote_link' => array(
  269. 'render element' => 'fn'
  270. ),
  271. 'footnote_list' => array(
  272. 'render element' => 'footnotes'
  273. )
  274. );
  275. }
  276. /**
  277. * Themed output of a footnote link appearing in the text body
  278. *
  279. * Accepts a single associative array, containing values on the following keys:
  280. * text - the raw unprocessed text extracted from within the [fn] tag
  281. * text_clean - a sanitized version of the previous, may be used as HTML attribute value
  282. * value - the raw unprocessed footnote number or other identifying label
  283. * fn_id - the globally unique identifier for the in-body footnote link
  284. * anchor, used to allow links from the list to the body
  285. * ref_id - the globally unique identifier for the footnote's anchor in the
  286. * footnote listing, used to allow links to the list from the body
  287. */
  288. function theme_footnote_link($fn) {
  289. // Drupal 7 requires we use "render element" which just introduces a wrapper
  290. // around the old array.
  291. $fn = $fn['fn'];
  292. return '<a class="see-footnote" id="' . $fn['ref_id'] .
  293. '" title="' . $fn['text_clean'] . '" href="#' . $fn['fn_id'] . '">' .
  294. $fn['value'] . '</a>';
  295. }
  296. /**
  297. * Themed output of the footnotes list appearing at at [footnotes]
  298. *
  299. * Accepts an array containing an ordered listing of associative arrays, each
  300. * containing values on the following keys:
  301. * text - the raw unprocessed text extracted from within the [fn] tag
  302. * text_clean - a sanitized version of the previous, may be used as HTML attribute value
  303. * value - the raw unprocessed footnote number or other identifying label
  304. * fn_id - the globally unique identifier for the in-body footnote link
  305. * anchor, used to allow links from the list to the body
  306. * ref_id - the globally unique identifier for the footnote's anchor in the
  307. * footnote listing, used to allow links to the list from the body
  308. */
  309. function theme_footnote_list($footnotes) {
  310. $str = '<ul class="footnotes">';
  311. // Drupal 7 requires we use "render element" which just introduces a wrapper
  312. // around the old array.
  313. $footnotes = $footnotes['footnotes'];
  314. // loop through the footnotes
  315. foreach ($footnotes as $fn) {
  316. if(!is_array( $fn['ref_id'])) {
  317. // Output normal footnote
  318. $str .= '<li class="footnote" id="' . $fn['fn_id'] .'"><a class="footnote-label" href="#' . $fn['ref_id'] . '">' . $fn['value'] . '.</a> ';
  319. $str .= $fn['text'] . "</li>\n";
  320. }
  321. else {
  322. // Output footnote that has more than one reference to it in the body.
  323. // The only difference is to insert backlinks to all references.
  324. // Helper: we need to enumerate a, b, c...
  325. $abc = str_split("abcdefghijklmnopqrstuvwxyz"); $i = 0;
  326. $str .= '<li class="footnote" id="' . $fn['fn_id'] .'"><a href="#' . $fn['ref_id'][0] . '" class="footnote-label">' . $fn['value'] . '.</a> ';
  327. foreach ($fn['ref_id'] as $ref) {
  328. $str .= '<a class="footnote-multi" href="#' . $ref . '">' . $abc[$i] . '.</a> ';
  329. $i++;
  330. }
  331. $str .= $fn['text'] . "</li>\n";
  332. }
  333. }
  334. $str .= "</ul>\n";
  335. return $str;
  336. }
  337. /**
  338. * Helper for other filters, check if Footnotes is present in your filter chain.
  339. *
  340. * Note: Due to changes in Filter API, the arguments to this function have changed
  341. * in Drupal 7.
  342. *
  343. * Other filters may leverage the Footnotes functionality in a simple way:
  344. * by outputting markup with <fn>...</fn> tags within.
  345. *
  346. * This creates a dependency, the Footnotes filter must be present later in
  347. * "Input format". By calling this helper function the other filters that
  348. * depend on Footnotes may check whether Footnotes is present later in the chain
  349. * of filters in the current Input format.
  350. *
  351. * If this function returns true, the caller may depend on Footnotes. Function returns
  352. * false if caller may not depend on Footnotes.
  353. *
  354. * You should also put "dependencies = footnotes" in your module.info file.
  355. *
  356. * Example usage:
  357. * <code>
  358. * _filter_example_process( $text, $filter, $format ) {
  359. * ...
  360. * if(footnotes_is_footnotes_later($format, $filter)) {
  361. * //output markup which may include [fn] tags
  362. * }
  363. * else {
  364. * // must make do without footnotes features
  365. * // can also emit warning/error that user should install and configure footnotes module
  366. * }
  367. * ...
  368. * }
  369. * </code>
  370. *
  371. * @param $format
  372. * The text format object caller is part of.
  373. * @param $caller_filter
  374. * The filter object representing the caller (in this text format).
  375. *
  376. * @return True if Footnotes is present after $caller in $format.
  377. */
  378. function footnotes_is_footnotes_later( $format, $caller_filter) {
  379. return $format['filter_footnotes']['weight'] > $caller_filter['weight'];
  380. }