You are here

footnotes.module in Footnotes 7.3

The Footnotes module is a filter that can be used to insert automatically numbered footnotes into Drupal texts.

File

footnotes.module
View source
<?php

/**
 * @file
 * The Footnotes module is a filter that can be used to insert
 * automatically numbered footnotes into Drupal texts.
 */

/**
 * Implementation of hook_help().
 */
function footnotes_help($path, $arg) {
  switch ($path) {
    case 'admin/help#footnotes':

      // This description is shown in the listing at admin/modules.
      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'),
      ));
  }
}

/**
 * Implementation of hook_filter_info().
 */
function footnotes_filter_info() {
  $filters['filter_footnotes'] = array(
    'title' => t('Footnotes'),
    'description' => t('Insert automatically numbered footnotes using &lt;fn&gt; or [fn] tags.'),
    'process callback' => '_footnotes_filter',
    'settings callback' => '_footnotes_settings',
    'default settings' => array(
      'footnotes_collapse' => 0,
    ),
    'tips callback' => '_footnotes_filter_tips',
    'weight' => -20,
  );
  return $filters;
}

/**
 * Short tips are provided on the content editing screen, while
 * long tips are provided on a separate linked page. Short tips are optional,
 * but long tips are highly recommended.
 */
function _footnotes_filter_tips($filter, $format, $long = FALSE) {
  if ($long) {
    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>',
    ));
  }
  else {
    return t('Use [fn]...[/fn] (or &lt;fn&gt;...&lt;/fn&gt;) to insert automatically numbered footnotes.');
  }
}

/**
 * Options for the Footnotes filter.
 *
 * This has currently 1 setting, the feature to collapse together footnotes
 * with identical content is an option.
 */
function _footnotes_settings($form, &$form_state, $filter, $format, $defaults, $filters) {
  $settings['footnotes_collapse'] = array(
    '#type' => 'checkbox',
    '#title' => t('Collapse footnotes with identical content'),
    '#default_value' => isset($filter->settings['footnotes_collapse']) ? $filter->settings['footnotes_collapse'] : $defaults['footnotes_collapse'],
    '#description' => t('If two footnotes have the exact same content, they will be collapsed into one as if using the same value="" attribute.'),
  );
  return $settings;
}

/**
 * The bulk of filtering work is done here.
 */
function _footnotes_filter($text = '', $filter, $format) {

  // Supporting both [fn] and <fn> now.
  // Thanks to fletchgqc http://drupal.org/node/268026.
  // Convert all square brackets to angle brackets. This way all further code
  // just manipulates angle brackets. (Angle brackets are preferred here for
  // the simple reason that square brackets are more tedious to use in regexps.)
  $text = preg_replace('|\\[fn([^\\]]*)\\]|', '<fn$1>', $text);
  $text = preg_replace('|\\[/fn\\]|', '</fn>', $text);
  $text = preg_replace('|\\[footnotes([^\\]]*)\\]|', '<footnotes$1>', $text);

  // Check that there are an even number of open and closing tags.
  // If there is one closing tag missing, append this to the end.
  // If there is more disparity, throw a warning and continue.
  // A closing tag may sometimes be missing when we are processing a teaser
  // and it has been cut in the middle of the footnote.
  // See http://drupal.org/node/253326
  $foo = array();
  $open_tags = preg_match_all("|<fn([^>]*)>|", $text, $foo);
  $close_tags = preg_match_all("|</fn>|", $text, $foo);
  if ($open_tags == $close_tags + 1) {
    $text = $text . '</fn>';
  }
  elseif ($open_tags > $close_tags + 1) {
    trigger_error(t("You have unclosed fn tags. This is invalid and will produce unpredictable results."));
  }

  // Before doing the replacement, the callback function needs to know which options to use.
  _footnotes_replace_callback($filter->settings['footnotes_collapse'], 'prepare');
  $pattern = '|<fn([^>]*)>(.*?)</fn>|s';
  $text = preg_replace_callback($pattern, '_footnotes_replace_callback', $text);

  // Replace tag <footnotes> with the list of footnotes.
  // If tag is not present, by default add the footnotes at the end.
  // Thanks to acp on drupal.org for this idea. See drupal.org/node/87226.
  $footer = _footnotes_replace_callback(NULL, 'output footer');
  $pattern = '|(<footnotes([^\\]]*)>)|';
  if (preg_match($pattern, $text) > 0) {
    $text = preg_replace($pattern, $footer, $text, 1);
    return $text;
  }
  else {
    return $text . "\n\n" . $footer;
  }
}

/**
 * Search the $store_matches array for footnote text that matches and return
 * the value.
 *
 * Note: This does a linear search on the $store_matches array. For a large
 * list of footnotes it would be more efficient to maintain a separate array
 * with the footnote content as key, in order to do a hash lookup at this
 * stage. Since you typically only have a handful of footnotes, this simple
 * search is assumed to be more efficient.
 * (but was not tested).
 *
 * @author djdevin (see http://drupal.org/node/808214)
 *
 * @param string The footnote text
 * @param array The matches array
 *
 * @return mixed The value of the existing footnote, FALSE otherwise
 */
function _footnotes_helper_find_footnote($text, &$store_matches) {
  if (!empty($store_matches)) {
    foreach ($store_matches as &$fn) {
      if ($fn['text'] == $text) {
        return $fn['value'];
      }
    }
  }
  return FALSE;
}

/**
 * Helper function called from preg_replace_callback() above
 *
 * Uses static vars to temporarily store footnotes found.
 * This is not threadsafe, but PHP isn't.
 */
function _footnotes_replace_callback($matches, $op = '') {
  static $opt_collapse = 0;
  static $n = 0;
  static $store_matches = array();
  static $used_values = array();
  $str = '';
  if ($op == 'prepare') {

    // In the 'prepare' case, the first argument contains the options to use.
    // The name 'matches' is incorrect, we just use the variable anyway.
    $opt_collapse = $matches;
    return 0;
  }
  if ($op == 'output footer') {
    if (count($store_matches) > 0) {

      // Only if there are stored fn matches, pass the array of fns to be themed
      // as a list
      // Drupal 7 requires we use "render element" which just introduces a wrapper
      // around the old array.
      $str = theme('footnote_list', array(
        'footnotes' => $store_matches,
      ));
    }

    // Reset the static variables so they can be used again next time
    $n = 0;
    $store_matches = array();
    $used_values = array();
    return $str;
  }

  // Default op: act as called by preg_replace_callback()
  // Random string used to ensure footnote id's are unique, even when contents
  // of multiple nodes reside on same page. Dixes http://drupal.org/node/194558.
  $randstr = _footnotes_helper_randstr();
  $value = '';

  // Did the pattern match anything in the <fn> tag?
  if ($matches[1]) {

    // See if value attribute can parsed, either well-formed in quotes eg <fn value="3">
    if (preg_match('|value=["\'](.*?)["\']|', $matches[1], $value_match)) {
      $value = $value_match[1];

      // Or without quotes eg <fn value=8>
    }
    elseif (preg_match('|value=(\\S*)|', $matches[1], $value_match)) {
      $value = $value_match[1];
    }
  }
  if ($value) {

    // A value label was found. If it is numeric, record it in $n so further notes
    // can increment from there.
    // After adding support for multiple references to same footnote in the body (http://drupal.org/node/636808)
    // also must check that $n is monotonously increasing
    if (is_numeric($value) && $n < $value) {
      $n = $value;
    }
  }
  elseif ($opt_collapse and $value_existing = _footnotes_helper_find_footnote($matches[2], $store_matches)) {

    // An identical footnote already exists. Set value to the previously existing value.
    $value = $value_existing;
  }
  else {

    // No value label, either a plain <fn> or unparsable attributes. Increment the
    // footnote counter, set label equal to it.
    $n++;
    $value = $n;
  }

  // Remove illegal characters from $value so it can be used as an HTML
  // id attribute.
  $value_id = preg_replace('|[^\\w\\-]|', '', $value);

  // Create a sanitized version of $text that is suitable for using as HTML
  // attribute value. (In particular, as the title attribute to the footnote link.)
  $allowed_tags = array();
  $text_clean = filter_xss($matches['2'], $allowed_tags);

  // HTML attribute cannot contain quotes
  $text_clean = str_replace('"', "&quot;", $text_clean);

  // Remove newlines. Browsers don't support them anyway and they'll confuse
  // line break converter in filter.module.
  $text_clean = str_replace("\n", " ", $text_clean);
  $text_clean = str_replace("\r", "", $text_clean);

  // Create a footnote item as an array.
  $fn = array(
    'value' => $value,
    'text' => $matches[2],
    'text_clean' => $text_clean,
    'fn_id' => 'footnote' . $value_id . '_' . $randstr,
    'ref_id' => 'footnoteref' . $value_id . '_' . $randstr,
  );

  // We now allow to repeat the footnote value label, in which case the link to
  // the previously existing footnote is returned. Content of the current
  // footnote is ignored.
  // See http://drupal.org/node/636808
  if (!in_array($value, $used_values)) {

    // This is the normal case, add the footnote to $store_matches.
    // Store the footnote item.
    array_push($store_matches, $fn);
    array_push($used_values, $value);
  }
  else {

    // A footnote with the same label already exists.
    // Use the text and id from the first footnote with this value.
    // Any text in this footnote is discarded.
    $i = array_search($value, $used_values);
    $fn['text'] = $store_matches[$i]['text'];
    $fn['text_clean'] = $store_matches[$i]['text_clean'];
    $fn['fn_id'] = $store_matches[$i]['fn_id'];

    // Push the new ref_id into the first occurence of this footnote label
    // The stored footnote thus holds a list of ref_id's rather than just one id
    $ref_array = is_array($store_matches[$i]['ref_id']) ? $store_matches[$i]['ref_id'] : array(
      $store_matches[$i]['ref_id'],
    );
    array_push($ref_array, $fn['ref_id']);
    $store_matches[$i]['ref_id'] = $ref_array;
  }

  // Return the item themed into a footnote link.
  // Drupal 7 requires we use "render element" which just introduces a wrapper
  // around the old array.
  $fn = array(
    'fn' => $fn,
  );
  return theme('footnote_link', $fn);
}

/**
 * Helper function to return a random text string
 *
 * @return random (lowercase) alphanumeric string
 */
function _footnotes_helper_randstr() {
  $chars = "abcdefghijklmnopqrstuwxyz1234567890";
  $str = "";

  // Seeding with srand() not neccessary in modern PHP versions.
  for ($i = 0; $i < 7; $i++) {
    $n = rand(0, strlen($chars) - 1);
    $str .= substr($chars, $n, 1);
  }
  return $str;
}

/**
 * Implementation of hook_theme()
 *
 * Thanks to emfabric for this implementation. http://drupal.org/node/221156
 */
function footnotes_theme() {
  return array(
    'footnote_link' => array(
      'render element' => 'fn',
    ),
    'footnote_list' => array(
      'render element' => 'footnotes',
    ),
  );
}

/**
 * Themed output of a footnote link appearing in the text body
 *
 * Accepts a single associative array, containing values on the following keys:
 * text - the raw unprocessed text extracted from within the [fn] tag
 * text_clean - a sanitized version of the previous, may be used as HTML
 * attribute value value - the raw unprocessed footnote number or other
 * identifying label fn_id - the globally unique identifier for the in-body
 * footnote link anchor, used to allow links from the list to the body ref_id -
 * the globally unique identifier for the footnote's anchor in the footnote
 * listing, used to allow links to the list from the body
 */
function theme_footnote_link($fn) {

  // Drupal 7 requires we use "render element" which just introduces a wrapper
  // around the old array.
  $fn = $fn['fn'];
  return '<a class="see-footnote" id="' . $fn['ref_id'] . '" title="' . $fn['text_clean'] . '" href="#' . $fn['fn_id'] . '">' . $fn['value'] . '</a>';
}

/**
 * Themed output of the footnotes list appearing at at [footnotes]
 *
 * Accepts an array containing an ordered listing of associative arrays, each
 * containing values on the following keys:
 *   text   - the raw unprocessed text extracted from within the [fn] tag
 *   text_clean   - a sanitized version of the previous, may be used as HTML
 * attribute value value  - the raw unprocessed footnote number or other
 * identifying label fn_id  - the globally unique identifier for the in-body
 * footnote link anchor, used to allow links from the list to the body ref_id -
 * the globally unique identifier for the footnote's anchor in the footnote
 * listing, used to allow links to the list from the body
 */
function theme_footnote_list($footnotes) {
  $str = '<ul class="footnotes">';

  // Drupal 7 requires we use "render element" which just introduces a wrapper
  // around the old array.
  $footnotes = $footnotes['footnotes'];

  // loop through the footnotes
  foreach ($footnotes as $fn) {
    if (!is_array($fn['ref_id'])) {

      // Output normal footnote.
      $str .= '<li class="footnote" id="' . $fn['fn_id'] . '"><a class="footnote-label" href="#' . $fn['ref_id'] . '">' . $fn['value'] . '.</a> ';
      $str .= $fn['text'] . "</li>\n";
    }
    else {

      // Output footnote that has more than one reference to it in the body.
      // The only difference is to insert backlinks to all references.
      // Helper: we need to enumerate a, b, c...
      $abc = str_split("abcdefghijklmnopqrstuvwxyz");
      $i = 0;
      $str .= '<li class="footnote" id="' . $fn['fn_id'] . '"><a href="#' . $fn['ref_id'][0] . '" class="footnote-label">' . $fn['value'] . '.</a> ';
      foreach ($fn['ref_id'] as $ref) {
        $str .= '<a class="footnote-multi" href="#' . $ref . '">' . $abc[$i] . '.</a> ';
        $i++;
      }
      $str .= $fn['text'] . "</li>\n";
    }
  }
  $str .= "</ul>\n";
  return $str;
}

/**
 * Helper for other filters, check if Footnotes is present in your filter
 * chain.
 *
 * Note: Due to changes in Filter API, the arguments to this function have
 * changed in Drupal 7.
 *
 * Other filters may leverage the Footnotes functionality in a simple way:
 * by outputting markup with <fn>...</fn> tags within.
 *
 * This creates a dependency, the Footnotes filter must be present later in
 * "Input format". By calling this helper function the other filters that
 * depend on Footnotes may check whether Footnotes is present later in the
 * chain
 * of filters in the current Input format.
 *
 * If this function returns true, the caller may depend on Footnotes. Function
 * returns false if caller may not depend on Footnotes.
 *
 * You should also put "dependencies = footnotes" in your module.info file.
 *
 * Example usage:
 * <code>
 * _filter_example_process( $text, $filter, $format ) {
 *   ...
 *   if(footnotes_is_footnotes_later($format, $filter)) {
 *     //output markup which may include [fn] tags
 *   }
 *   else {
 *     // must make do without footnotes features
 *     // can also emit warning/error that user should install and configure
 * footnotes module
 *   }
 *   ...
 * }
 * </code>
 *
 * @param $format
 *    The text format object caller is part of.
 * @param $caller_filter
 *    The filter object representing the caller (in this text format).
 *
 * @return True if Footnotes is present after $caller in $format.
 */
function footnotes_is_footnotes_later($format, $caller_filter) {
  return $format['filter_footnotes']['weight'] > $caller_filter['weight'];
}

Functions

Namesort descending Description
footnotes_filter_info Implementation of hook_filter_info().
footnotes_help Implementation of hook_help().
footnotes_is_footnotes_later Helper for other filters, check if Footnotes is present in your filter chain.
footnotes_theme Implementation of hook_theme()
theme_footnote_link Themed output of a footnote link appearing in the text body
theme_footnote_list Themed output of the footnotes list appearing at at [footnotes]
_footnotes_filter The bulk of filtering work is done here.
_footnotes_filter_tips Short tips are provided on the content editing screen, while long tips are provided on a separate linked page. Short tips are optional, but long tips are highly recommended.
_footnotes_helper_find_footnote Search the $store_matches array for footnote text that matches and return the value.
_footnotes_helper_randstr Helper function to return a random text string
_footnotes_replace_callback Helper function called from preg_replace_callback() above
_footnotes_settings Options for the Footnotes filter.