Ruby
Rails

100文字以上の文字列はカットして欲しいって言われた時の話

お客様より

・DBからテキストを引っ張ってきて画面に出して欲しい。
・HTMLタグが文章中に含まれているが、エスケープせず印字して欲しい。
└ <strong>タグと<sub>タグ
・文字数が100文字以上の場合、その先は省略して欲しい(文末に「...」も出してほしい)
HTMLタグは100文字の文字数に含めないで欲しいが、当然画面には反映しろ。

という依頼を受けた。

ざっくり実装

text.erb
strong_array = text.scan(/<strong>(.*?)<\/strong>/)
sub_array = text.scan(/<sub>(.*?)<\/sub>/)

text = text.gsub(/<strong>|<\/strong>/,'')
text = text.gsub(/<sub>|<\/sub>/,'')

text = text.match(/[\s\S]{1,100}/)

strong_array.each do |val|
  text = text.gsub(val,'<strong>' + val + '</strong>')
end 

sub_array.each do |val|
  text = text.gsub(val,'<sub>' + val + '</sub>')
end

<%= raw text %>

さー画面に印字テストーーーーってところで問題が。
<strong>タグで囲まれた部分は、「記号+文字列」で構成された所謂見出し部分なので、
基本的に他のテキストと被ることが無いのでギリセーフ。パリング成功。

が、<sub>タグで囲まれた所が問題だった。
数字だったのだ。
例:(注釈<sub>1</sub>)のような感じ

そんなわけだから、安易にgsubなんかかけたらそりゃあデータが狂う狂う。

泣く泣く別の実装を探すも、私の探し方が悪いからか、
「文字列をn文字引っ張ってくる。
その際に特定の文字列は文字数カウントしない。
結果にはその弾いた文字列を含む」
なんて便利メソッドはどこにも見つからなかった。

正規表現も、私はせいぜいギラ程度のレベルしか扱えない。

召喚魔法も、上司が休暇では使えない。

こうなればもう。原点回帰の呪術(ゴリ押し)で組むしかない。

呪術:内なる大力

text.erb
<%
# およそ使わないであろう文字に変換
text.gsub!(/<strong>/, '★★★★★★★★')
text.gsub!(/<\/strong>/, '★★★★★★★★★')
text.gsub!(/<sub>/, '★★★★★')
text.gsub!(/<\/sub>/, '★★★★★★')
# 文字列を、1文字区切りの配列に変換する
safety_array = text.split("")
count = 0
index = 0
# 置き換えた文字を省いてカウントを進め、100文字目のindexを取得する
safety_array.each_with_index do |val, i|
  index = i
  next if val === '★'
  break if count === 100
  count += 1
end

# 閉じタグが付かずに終わった場合、該当のタグのカウントが-1となる
strong_tag_count = safety.scan('<strong>').size - safety.scan('</strong>').size
sub_tag_count = safety.scan('<sub>').size - safety.scan('</sub>').size
end_of_sentence = ""
end_of_sentence << '</sub>' if sub_tag_count > 0
end_of_sentence << '</strong>' if strong_tag_count > 0
end_of_sentence << '...'
%>

<%= raw text + end_of_sentence %>

無い頭を捻ったところで、この程度の実装しか出てこなかった。
「およそ使わないであろう文字」は制御文字で良かったかもしれない、
タグ置き換え部分はタグを配列に入れて回す方がスマートだったかもしれない などと思いつつ、
一応の完成とするのであった。

同じ場面に遭遇した魔法使いが居ればなるべく参考にはしないで欲しいし、
鼻で笑いながらツザリクでも唱えて欲しい。

願わくば、これを見た冒険者がイケてる実装を記してくれることを願い、この手記を終わりとする。

追記

ぶった切るならコレで良いじゃん。

array.scan(/(<strong>|<\/strong>|<sub>|<\/sub>|\S)/).flatten

追記2

最終的にこうなった。

  def self.cutter(html,length,count = 0,cut = '',tags = [])
    if count < 1
      # タグナシの文字数が規定文字数以下ならそのまま返す
      return html if html.gsub(/<strong>|<\/strong>|<sub>|<\/sub>/,'').length <= length
      # 文章は1文字ごとに、タグは文字列で格納 改行も諸事情で1文字とする
      html = html.scan(/(<strong>|<\/strong>|<sub>|<\/sub>|\S|\n)/).flatten
    end
    target = html.shift
    # 開始タグのみ取得 その際、閉じタグ化 2文字以上はタグ また、/が含まれるのは閉じタグなので無視
    tags.unshift(target.clone.insert(1,'/')) if target.length > 1 && !target.include?('/')
    # 閉じタグをリストから除去 開始タグ取得時に閉じタグ化しているので、閉じタグが来たら判別できる
    tags.shift if tags.first.eql?(target)
    cut << target
    # 文字数に数えるのはタグ以外
    count += 1 if target.length == 1
    if count == length
      # 閉じられていないタグがあるならば、登場順に適用する
      # <strong>hoge<sup>1 とかで終わっても大丈夫
      tags.each do |val|
        cut << val
      end
      cut << '…'
      return cut
    end
    # 再帰処理
    cutter(html,length,count,cut,tags)
  end