mlcastle

  

Awesome Truncation in Rails

For a project I’m working on, we’d like to have the rails truncate helper truncate strings at word boundaries rather than at the in the middle of a word after exactly a certain number of characters.

Daniel Morrison’s awesome_truncate provides the basic idea, but it has a few problems:

  • it’s not compatible with Ruby 1.9
  • it doesn’t support the new Rails 2.2 calling convention for truncate, in which options are passed as a hash rather than a list
  • you have to replace your calls to truncate with calls to awesome_truncate

In addition, our client asked us to ensure that there is never a single word on a line by itself at the end of the truncated string.

Solution

So, here is the solution I came up with. It overrides the default truncate method with one which correctly refuses to split words in the middle, which can be called with either hash-style or list-style arguments, which supports three different ways of dealing with multibyte strings, and which supports a new :avoid_orphans option for avoiding leaving a word on a line by itself.

   1  module ActionView::Helpers::TextHelper
   2    def truncate(text, *args)
   3      options = args.extract_options!
   4  
   5      # support either old or Rails 2.2 calling convention:
   6      unless args.empty?
   7        options[:length] = args[0] || 30
   8        options[:omission] = args[1] || "..."
   9      end
  10      options.reverse_merge!(:length => 30, :omission => "...")
  11  
  12      # support any of:
  13      #  * ruby 1.9 sane utf8 support
  14      #  * rails 2.1 workaround for crappy ruby 1.8 utf8 support
  15      #  * rails 2.2 workaround for crappy ruby 1.8 utf8 support
  16      # hooray!
  17      if text
  18        chars = if text.respond_to?(:mb_chars)
  19          text.mb_chars
  20        elsif RUBY_VERSION < '1.9'
  21          text.chars
  22        else
  23          text
  24        end
  25  
  26        omission = if options[:omission].respond_to?(:mb_chars)
  27          options[:omission].mb_chars
  28        elsif RUBY_VERSION < '1.9'
  29          options[:omission].chars
  30        else
  31          options[:omission]
  32        end
  33  
  34        l = options[:length] - omission.length
  35        if chars.length > options[:length]
  36          result = (chars[/\A.{#{l}}\w*\;?/m][/.*[\w\;]/m]).to_s
  37          ((options[:avoid_orphans] && result =~ /\A(.*?)\n+\W*\w*\W*\Z/m) ? $1 : result) + options[:omission]
  38        else
  39          text
  40        end
  41      end
  42    end
  43  end

I put this code into RAILS_ROOT/config/initializers/use_awesome_truncate.rb; this may not be the most sane place for it, but it does work.

Of course, if you don’t want to override the default truncate, you could call the method something else and put it in ApplicationHelper.

Does this look like too much code for the job? Yes. But most of the code is dedicated to dealing with multibyte string handling, which is unfortunately tricky. Once we get to Ruby 1.9, I understand things will get better, but of course we need to deal with the environment in which our code runs today.

Unit Test

Here is a test for the code, if you’re interested. I put it in test/unit/truncate_test.rb.

   1  require File.dirname(__FILE__) + '/../test_helper'
   2  
   3  class TruncateTest < Test::Unit::TestCase
   4    include ActionView::Helpers::TextHelper
   5  
   6    def test_short
   7      assert_equal "hello", truncate("hello")
   8      assert_equal "hello there this is a test nothing more", truncate("hello there this is a test nothing more", 50)
   9    end
  10  
  11    def test_long
  12      assert_equal "hello...", truncate("hello there", 3)
  13      assert_equal "hello there this is a test nothing...", truncate("hello there this is a test nothing more than that my good man")
  14    end
  15  
  16    def test_multibyte
  17      assert_equal "ɦɛĺłø...", truncate("ɦɛĺłø ŵőřļđ", 3)
  18      assert_equal "ɦɛĺłø ŵőřļđ", truncate("ɦɛĺłø ŵőřļđ", 12)
  19    end
  20  
  21    def test_new_calling_convention
  22      assert_equal "hello…", truncate("hello world", :length => 3, :omission => "…")
  23      assert_equal "hello world", truncate("hello world", :omission => "…")
  24      assert_equal "hello...", truncate("hello world", :length => 3)
  25    end
  26    
  27    def test_paragraph_truncate
  28      assert_equal "This line stands alone.…", truncate("This line stands alone.\nNobody should see this.", :length => 28, :avoid_orphans => true, :omission => "…")
  29    end
  30    
  31    def test_multi_paragraph_truncate
  32      (18..20).to_a.each do |len|
  33        assert_equal "Para 1\n\nPara 2...", truncate("Para 1\n\nPara 2\n\nPara 3", :length => len, :avoid_orphans => true)
  34      end
  35      assert_equal "Para 1\n\nPara 2\n\nPara 3", truncate("Para 1\n\nPara 2\n\nPara 3", :length => 22, :avoid_orphans => true)
  36    end
  37  end

Enjoy, and feel free to post below if you have any further improvements or questions.

Update: Would you like to download the code? Yes, you would, it seems. How about the tests, as well?

Posted on Friday the 30th of January 2009 at 3:25 PM

  1. mlcastle posted this