Ruby: How to implement word wrapping that ignores <span> tags when calculating line length?
I need to write a small ruby function that does word wrap. I have the following function:
def word_wrap(text, line_width)
if line_width.nil? || line_width < 2
line_width = 40
end
text.split("\n").collect do |line|
line.length > line_width ? line.gsub(/.{1,#{line_width}})(\s+|$)/, "\\1\n").strip : line
end * "\n"
end
It's basically the word_wrap feature included in Rails.
I would like to write a similar function that parses a string with span elements inside, except that tags do not have to be counted to wrap the string.
Example:
s = "Lorem <span>ipsum dolor</span> si<span>t</span> amet, conse<span>ctetur adipiscing elit</span> Praesent"
Currently word_wrap (s, 20) gives something like this:
Lorem <span>ipsum
dolor</span>
si<span>t</span>
amet,
conse<span>ctetur
adipiscing
elit</span> Praesent
It should be:
Lorem <span>ipsum dolor</span> si
<span>t</span> amet, conse<span>ctetur
adipiscing elit</span>
Praesent
As you can see, the new feature creates a line word_wrap (maximum) 20 characters, excluding the tag <span>
and </span>
.
How do you do it? All suggestions are welcome!
Thanks in advance for your help.
Here's the regex
irb> SPAN_RE = /(?i:<\/?span[^>]*>)/
#=> /(?i:<\/?span[^>]*>)/
irb> ALL_SPANS_RE = /(?:#{SPAN_RE}*(?!#{SPAN_RE}))/
#=> /(?:(?i-mx:<\/?span[^>]*>)*(?!(?i-mx:<\/?span[^>]*>)))/
irb> def word_wrap(str,width)
full_re = /((?:#{ALL_SPANS_RE}.){0,#{width-1}}#{ALL_SPANS_RE}\S(?:#{SPAN_RE}+|\b))\s*/
str.gsub(/\s*\n/, ' ').gsub(full_re, "\\1\n")
end
#=> nil
irb> text =<<TEXT
Lorem <span>ipsum
dolor</span>
si<span>t</span>
amet,
conse<span>ctetur
adipiscing
elit</span> Praesent
TEXT
#=> "Lorem <span>ipsum\ndolor</span>\nsi<span>t</span>\namet,\nconse<span>ctetur\nadipiscing\nelit</span> Praesent\n"
irb> puts word_wrap(text,20)
Lorem <span>ipsum dolor</span> si<span>
t</span> amet, conse<span>ctetur
adipiscing elit</span>
Praesent
#=> nil
irb> word_wrap(text,20)
#=> "Lorem <span>ipsum dolor</span> si<span>\nt</span> amet, conse<span>ctetur\nadipiscing elit</span>\nPraesent\n"
Basically we grab as many characters as we can up to the width of the word, ignoring the spacing (and making sure we don’t capture parts of the spans), and making sure we end up with a nonspatial character followed by either a break or a word.
I'll break down how the regex works:
SPAN_RE
matches a single span tag (either <span>
, or </span>
or or ...)
(?i: - Start of a non-capturing parenthesis (useful for grouping patterns)
The i flag means the inner pattern is case-insensitive
< - a literal '<' character
\/? - 0 or 1 a forward slashes
span - the letters "span"
[^>]* - 0 or more other characters that are not a '>' character
> - a literal '>' character
) - end of the non-capturing parenthesis
ALL_SPAN_RE
matches all spans at a given position - ensuring that the next character that matches is not the start of a span tag.
(?: - Start of a non-capturing parenthesis (useful for grouping patterns)
#{SPAN_RE}* - 0 or more spans
(?! - Start of a negative lookahead
#{SPAN_RE} - exactly 1 span
Since this is inside a negative lookahead, it means that the next
character in the string is not allowed to start a span
) - end of the negative lookahead
) - end of the non-capturing parenthesis
This means that we can match one character after ALL_SPAN_RE
and be sure that we are not capturing part of the span.
full_re
then just greedily matches as many characters as it can to the desired width (excluding spans), making sure it ends with a nonspatial character that is either word end or spacing.
( - start of a capturing parenthesis
(?: - start of a non-capturing parenthesis
#{ALL_SPANS_RE} - any and all spans
. - one character (which can't be the start of a span)
) - end of non-capturing parenthesis
{0,#{width-1}} - match preceding pattern up to width-1 times
so this matches width-1 characters (ignoring spans)
#{ALL_SPANS_RE} - any and all spans
\S - a non-whitespace character
we don't want to insert a "\n" after whitespace
(?: - a non-capturing parenthesis
#{SPAN_RE}+ - 1 or more spans
| - OR
\b - the end of a word
these alternatives makes sure we aren't breaking in the middle of a word
) - end of non-capturing parentheis
) - end of capturing parenthesis
\s* - any whitespace
since we're wrapping, we can just toss this when we insert the newline
a source to share
It's fun! (although has a little housework)
I deleted my previous attempts, you can check the answer history if interested. Here's my final answer, slightly longer vertically but more on the ground and not as hacky as the previous one.
s = "Lorem <span>ipsum dolor</span> si<span>t</span> amet, conse<span>ctetur adipiscing elit</span> Praesent"
def word_wrap(s_arg, line_width = 40)
producer = s_arg.dup
consumer = ""
counter = 0
while !producer.empty?
if producer =~ %r[\A</?span>]
consumer << producer.slice!(0, $&.length)
next
end
consumer << producer.slice!(0, 1)
counter += 1
next if counter <= line_width
consumer.sub!(/ (\S*?)\z/, "\n\\1")
counter = $1.length
end
consumer
end
puts word_wrap(s, 20)
a source to share