How can the_excerpt (or equivalent) be called on a category description?

wp_html_excerpt($category->description, 25).

Or you can create your own function. I’m using this:

/**
 * Filters content based on specific parameters, and appends a "read more" link if needed.
 * Based on the "Advanced Excerpt" plugin by Bas van Doren - http://sparepencil.com/code/advanced-excerpt/
 *
 * @since 1.0
 *
 * @param string $content What to filter, defaults to get_the_content(); should be left empty if we're filtering post content
 * @param array $args Optional arguments (limit, allowed tags, enable/disable shortcodes, read more link)
 * @return string Filtered content
 */
function atom_filter_content($content = NULL, $args = array()){

  $args = wp_parse_args($args, array(
      'limit' => 40,
      'allowed_tags' => array('a', 'abbr', 'acronym', 'address', 'b', 'big', 'blockquote', 'cite', 'code', 'dd', 'del', 'dfn', 'div', 'dl', 'dt', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'i', 'ins', 'li', 'ol', 'p', 'pre', 'q', 'small', 'span', 'strong', 'sub', 'sup', 'tt', 'ul'),
      'shortcodes' => false,
      'more' => '<a href="'.get_permalink().'" class="more-link">'.__('More &gt;').'</a>',
    ));

  extract(apply_filters('atom_content_filter_args', $args, $content), EXTR_SKIP);

  if(!isset($content)) $text = get_the_content(); else $text = $content;
  if(!$shortcodes) $text = strip_shortcodes($text);

  if(!isset($content)) $text = apply_filters('the_content', $text);

  // From the default wp_trim_excerpt():
  // Some kind of precaution against malformed CDATA in RSS feeds I suppose
  $text = str_replace(']]>', ']]&gt;', $text);

  // Strip HTML if allow-all is not set
  if(!in_array('ALL', $allowed_tags)):
    if(count($allowed_tags) > 0) $tag_string = '<'.implode('><', $allowed_tags).'>'; else $tag_string = '';
    $text = strip_tags($text, $tag_string); // @todo: find a way to use the function above (strip certain tags with the content between them)
  endif;

  // Skip if text is already within limit
  if($limit >= count(preg_split('/[\s]+/', strip_tags($text)))) return $text;

  // Split on whitespace and start counting (for real)
  $text_bits = preg_split('/([\s]+)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
  $in_tag = false;
  $n_words = 0;
  $text="";
  foreach($text_bits as $chunk):
    if(!$in_tag || strpos($chunk, '>') !== false) $in_tag = (strrpos($chunk, '>') < strrpos($chunk, '<'));

    // Whitespace outside tags is word separator
    if(!$in_tag && '' == trim($chunk)) $n_words++;

    if($n_words >= $limit && !$in_tag) break;
    $text .= $chunk;
  endforeach;

  $text = trim(force_balance_tags($text));

  if($more):
    $more = " {$more}";
    if(($pos = strpos($text, '</p>', strlen($text) - 7)) !== false):
      // Stay inside the last paragraph (if it's in the last 6 characters)
      $text = substr_replace($text, $more, $pos, 0);
    else:

     // If <p> is an allowed tag, wrap read more link for consistency with excerpt markup
     if(in_array('ALL', $allowed_tags) || in_array('p', $allowed_tags))
       $more = "<p>{$more}</p>";
       $text = $text.$more;
     endif;
  endif;
  return $text;
}