How to fold / wrap the content after number of characters

I would like to create an ICS file from _events collection. The iCal standard demands newlines (plus then two spaces) after 75 characters. How would I write a template for this? Is there a filter out there that for example works similar to the truncate filter:

DESCRIPTION:{{ event.content | fold: 75, "  " }}

Can you insert newlines using a substring?

Can you show an example snippet with which I can try what you are trying to suggest?

It would be useful if you share an example of the content and then what you want the output to look like. I’m struggling a bit to understand the requirement. For example, do you just want the first 75 characters or if it is n number of characters, like say, 200, then you want 75 + extra space + 75 + extra space + 50 + no extra space?

Here is the template for the iCalendar file event.ics:

---
---

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//HaeMa//Jekyll ICS Export//DE
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALDESC;VALUE=TEXT:HängeMathe Termine
X-WR-CALNAME;VALUE=TEXT:Club HängeMathe
X-WR-TIMEZONE;VALUE=TEXT:Europe/Berlin
{% for event in site.events %}{% if event.preview != true %}BEGIN:VEVENT
METHOD:PUBLISH
UID:{{ event.date | date: "%Y%m%dT%H%M%S" }}@club-haengemathe.de
DTSTART:{{ event.date | date: "%Y%m%dT%H%M%S" }}
DTSTAMP:{{ event.date | date: "%Y%m%dT%H%M%S" }}
DURATION:PT4H
SUMMARY:{{ event.title | escape }}
URL:{{ event.url | absolute_url }}
LOCATION:Club HängeMathe, Zeunerstr. 1f, 01069 Dresden
{% if event.image %}IMAGE;VALUE=URI;DISPLAY=FULLSIZE:{{ event.image | absolute_url }}
{% endif %}END:VEVENT
{% endif %}{% endfor %}END:VCALENDAR

This results in an iCalendar file that has entries like this:

BEGIN:VEVENT
METHOD:PUBLISH
UID:20220513T200000@club-haengemathe.de
DTSTART:20220513T200000
DTSTAMP:20220513T200000
DURATION:PT4H
SUMMARY:Lesung - Das Kabinett der Absurditäten
URL:https://www.club-haengemathe.de/2022/05/13/kabinett-der-absurdit%C3%A4ten.html
LOCATION:Club HängeMathe, Zeunerstr. 1f, 01069 Dresden
IMAGE;VALUE=URI;DISPLAY=FULLSIZE:https://www.club-haengemathe.de/images/2022-05-13-kabinett-der-absurdit%C3%A4ten.jpg
END:VEVENT

Here is an entry of the same event in another calendar built by a different script:

BEGIN:VEVENT
METHOD:PUBLISH
DESCRIPTION:Hattest du nicht auch schon mal einen angebrochenen Nachmitta
  g und hast dich gefragt, was Pflanzen so in ihrer Freizeit machen? Oder w
  as eine Wursttrommel ist? Woher kommen wir? Wohin gehen wir?  Ist die Kar
  te "Wuschel kommt" in Bauwoche ßberfällig? Diese traditionellen Fragen de
  r Philosophie werden heute beantwortet.
X-ALT-DESC;FMTTYPE=text/html:<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
   Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.d
  td"><html><body>Hattest du nicht auch schon mal einen angebrochenen Nachm
  ittag und hast dich gefragt, was Pflanzen so in ihrer Freizeit machen? Od
  er was eine Wursttrommel ist? Woher kommen wir? Wohin gehen wir?  Ist die
   Karte &quot;Wuschel kommt&quot; in Bauwoche ßberfällig? Diese traditione
  llen Fragen der Philosophie werden heute beantwortet.</body></html>
DTSTART:20220513T200000
DTSTAMP:20220513T200000
SUMMARY:Lesung: Das Kabinett der Absurditäten
LOCATION:Club HängeMathe
GEO:51.0267454,13.7367732
URL:https://www.exmatrikulationsamt.de/6083616
UID:www.exmatrikulationsamt.de/6083616
DURATION:PT6H
END:VEVENT

While the entries differ in certain ways, you can see that the DESCRIPTION field breaks at 74 + 4 spaces = 78 characters. Even though even words are broken in between, this validates against RFC 5545, while the other one creates warnings about the line lengths.

Okay, I found a solution for you, and I hope you use it because it was a lot of work :slight_smile:

There is no fold function, and honestly, there is no easy way to do what you are asking with Liquid. I did find what I would call a workaround for the solution, and I think it should generally work. Maybe you can play around with the logic, but I found it to work with <= 74 characters and >74 characters.

:vertical_traffic_light: Liquid limitations

Let’s list out what you cannot do first:

  • Add a new line (carriage return) for every n number of characters
  • Add spaces for every n number of characters

:fast_forward: The approach

What you can do is use the slice filter, which will, in your case, will grab 74 characters and put them into a new variable. With that variable, you can add extra spaces to the beginning.

We do not know how many rows the event description will create, so we should store each new line as a row in an array. But that causes a problem because you can’t technically create an array and start stuffing items into it. Instead, you need to get all your text and use the split filter to convert your string into rows within an array. You need some character to key off, so I selected the pipe (|) character. You can choose something else. The trick is to make sure you never (ever) use that character in the event description.

What we want

Grab every instance of 74 characters, add two spaces to the beginning, and put it into an array. Do not add spaces to the first line.

What we will do

  • Grab every instance of 74 characters into an array
  • Prepend two spaces to the start of each line (we will strip out the first two spaces later)
  • Append each line with a separator character
  • Remove the leading spaces on the first line where the description is located using the liquid lstrip filter
  • Remove the last separator character from the end of the string using the slice filter

We will end up with a string that looks something like this:

DESCRIPTION:Lorem ipsum dolor sit amet consectetur adipisicing elit. Quam |  laudantium sapiente blanditiis provident praesentium amet ad unde consecte|  tur placeat voluptates? Officiis ipsa praesentium esse ipsam quibusdam nob

With that code in place, we can use the Liquid split filter to break up the text into rows in an array based on our separator. We can then use that array to build the iCal event details.

:technologist: The code

You will note that I output a bunch of these variables to the screen so you can verify the numbers first, so feel free to comment or remove them when you are okay with it. First, I will provide all the code, and then I will describe what is happening:

ical.md
---
layout: default
---

{% comment %} Create a string that represents the description for an iCal event {% endcomment %}
{% comment %} >74 characters {% endcomment %}
{% assign eventOrig = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Quam laudantium sapiente blanditiis provident praesentium amet ad unde consectetur placeat voluptates? Officiis ipsa praesentium esse ipsam quibusdam nobis, aliquam voluptate eveniet laborum possimus similique aperiam quo iste repudiandae repellendus accusantium blanditiis nam, inventore atque facere natus a. Ratione et ea minus quis pariatur quisquam corrupti eum nulla officia cumque earum temporibus deserunt' %}
{% comment %} uncomment to test with up to 74 characters {% endcomment %}
{% comment %} {% assign eventOrig = 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Quams' %} {% endcomment %}

{% comment %} Put the word "DESCRIPTION:" in front of the first line {% endcomment %}
{% assign eventOrig = eventOrig | prepend: 'DESCRIPTION:' %}

{% comment %} Determine the number of rows, assuming 74 characters {% endcomment %}
{% assign eventChars = eventOrig.size %}
{% assign stringsToGet = 74.00 %}
{% assign getiCalRows = eventChars | divided_by: stringsToGet | round %}
{% assign iCalRows = getiCalRows | round %}
eventChars: {{eventChars}}

iCalRows: {{iCalRows}}

{% comment %} Define string path elements {% endcomment %}
{% assign start = 0 %}
{% assign adder = 74 %}
{% comment %} Define the number of spaces to add before each new line {% endcomment %}
{% assign spaces = '  ' %}
{% comment %} Define the separator character that will be used to turn the event into an array {% endcomment %}
{% assign appender = '|' %}
{% comment %} Empty string that will later contain the new event {% endcomment %}
{%- assign iCalEvent = '' -%}
{% comment %} Represents a single row for the event {% endcomment %}
{% assign iCalEventRow = '' %}

{% for i in (1..iCalRows) %}
    {% comment %} If the event is only one line, do nothing {% endcomment %}
    {% if eventChars <= stringsToGet  %}
        {% assign iCalEvent = eventOrig %}
        {% break %}
    {% else %}
        {% assign iCalEventRow = eventOrig | slice: start, adder | prepend: spaces | append: appender %}
        {% assign iCalEvent = iCalEvent | append: iCalEventRow %}
        {% assign start = start | plus: adder %}
    {% endif %}
{% endfor %}

{% comment %} With the string in place, convert each line to an array {% endcomment %}
{% if eventChars >= stringsToGet  %}
    {% assign iCalEvent = iCalEvent | lstrip %}
    {% assign lastChar = iCalEvent.size | minus: 1 %}
    {% assign iCalEvent = iCalEvent | slice:0,lastChar %}
    {% assign iCalDescriptions = iCalEvent | split: appender %}
{% endif %}

iCalDescriptions (size: {{iCalDescriptions.size}})
{% for desc in iCalDescriptions %}
    {{desc}}
{% endfor %}


Original event (size: {{eventOrig.size}}):

{{eventOrig}}

:walking_man: Code walkthrough

eventOrig is the original content you use for the event. In this case, make sure it does not contain the | separator. I prepend that same string with the DESCRIPTION: text to make life easier.

Determining rows

{% assign eventChars = eventOrig.size %}
{% assign stringsToGet = 74.00 %}
{% assign getiCalRows = eventChars | divided_by: stringsToGet | round %}
{% assign iCalRows = getiCalRows | round %}

I assume every line must be 74 characters, but it is really 76 because you add two spaces to the beginning (except for the first DESCRIPTION line).

To figure out how many rows you have, I divide the total number of characters in the event by the float number of 74.00. That float number is essential because had I just used 74, Liquid would have rounded down to 6 lines when you really have 6.5 lines. You always need to round up, and the round filter will not work without changing 74 to 74.00.

String path elements

We will walk through the string, collecting blocks of 74 characters along the way.

{% comment %} Define string path elements {% endcomment %}
{% assign start = 0 %}
{% assign adder = 74 %}

Later, we will introduce the use of the Liquid slice filter. We will tell the code where to start, which is position zero, and then how many characters to get after that, which in this case is 74.

Using my sample text, there will be 7 lines for the description. During the for loop, the new start variable will look like this:

Loop 1: 0
Loop 2: 74
Loop 3: 148
Loop 4: 222
Loop 5: 296
Loop 6: 370
Loop 7: 444

The adder will be used for two different purposes. First, it will add 74 to the start integer, as I show above. Second, it will tell the slice filter how many characters to get.

Separator variables

{% assign spaces = '  ' %}
{% assign appender = '|' %}

The spaces variable determines how many spaces to add for each line of the DESCRIPTION. I put two spaces in there, so you can increase to however many you need.

As mentioned earlier in this post, our string containing the event will be converted into an array using the Liquid split filter. I selected the pipe character (|), but you can choose something else. Never use that character in the description for one of your events or the code breaks.

Placeholder strings

{%- assign iCalEvent = '' -%}
{% assign iCalEventRow = '' %}

As you loop through the descriptions, you will create a temporary iCalEventRow to get the text for a description row, prepend it with some spaces, and append it with the appender character.

When complete, our string that contains all the spaces and appenders required, you will end up with an iCalEvent. Later, we will populate the iCalDescriptions array with the contents of iCalEvent.

The for loop

{% for i in (1..iCalRows) %}
    {% comment %} If the event is only one line, do nothing {% endcomment %}
    {% if eventChars <= stringsToGet  %}
        {% assign iCalEvent = eventOrig %}
        {% break %}
    {% else %}
        {% assign iCalEventRow = eventOrig | slice: start, adder | prepend: spaces | append: appender %}
        {% assign iCalEvent = iCalEvent | append: iCalEventRow %}
        {% assign start = start | plus: adder %}
    {% endif %}
{% endfor %}

Do nothing if the description line (which also includes the text DESCRIPTION: is <= 74 characters.

If there are more than 74 characters, begging collecting each “row” of data as determined by the iCalRows integer.

  1. Slice out the appropriate 74 characters of text based on the current for loop number. Also, prepend the two spaces and append the | appender.
  2. Build the iCalEvent string to include the 74 characters + 2 spaces + |
  3. Increment the start value, so the slicer gets the next group of 74 characters.

Cleanup and array creation

The iCalEvent string now has all the text, with added spaces and a | separator. However, we do have a little cleanup to do.

{% comment %} With the string in place, convert each line to an array {% endcomment %}
{% if eventChars >= stringsToGet  %}
    {% assign iCalEvent = iCalEvent | lstrip %}
    {% assign lastChar = iCalEvent.size | minus: 1 %}
    {% assign iCalEvent = iCalEvent | slice:0,lastChar %}
    {% assign iCalDescriptions = iCalEvent | split: appender %}
{% endif %}
  • Use the lstrip filter to remove any leading space at the front of the string
  • Use the slice filter once more to remove the last character in the string, which will be the | character (the appender string)
  • Now that we have the string cleaned up, use the Liquid split filter to look for all the text separated by the appender | character and create an array with the name iCalDescriptions

Use the iCalDescriptions array

The following code shows how you can loop through each line of the description. In this case, it displays the text on the website page. You can, of course, add this to the variable you build for the rest of the event.

iCalDescriptions (size: {{iCalDescriptions.size}})
{% for desc in iCalDescriptions %}
    {{desc}}
{% endfor %}

Potential bugs

The following might be problems with my code, so please do some testing:

  1. When I did the round-up math to calculate how many rows, I used a number of characters that ended up being 6.51 rows, so it rounded up to 7. I do not know if I had fewer characters if it would still round up. That could result in a bug where there are characters that do not show up in the final iCal description.
  2. I do not know if there are any special characters iCal does not support, so you might want to strip those out of the string before using the Liquid replace filter.
  3. Speaking of special characters, you may want to use that same replace filter to remove the special appender character. That way, you do not have to worry about someone typing something that you will be using as part of your code.

Okay, I hope that helps!

3 Likes

@BillRaymond Thank you very much for your effort, the very verbose explanation and of course your code snippet!

It does its work on first sight, so far so good. It created lots of blank lines before and in between though, which again creates warnings in the validator. Also all the looping through the arrays makes the code inside the events.ics way more messy than regarded acceptable here. I guess Liquid is not really meant to be used this way, right?

But with the knowledge that there is no easy filter around and your code proposal I will look into how to create jekyll plugins so that I might be able to write the filter myself. Any hints on this much appreciated!

1 Like

Thank you @mcnesium for asking this question, and @BillRaymond for your awesome answer!

That’s because of all the white lines in the code and the newlines created for all ‘assign’, ‘if’, ‘for’ and ‘comment’ tags. That’s relatively easily remedied by adding a - in each of the tags (since Liquid 4.0): Whitespace control – Liquid template language.

Again, thanks so much for sharing. It was just what I was looking for! Please permit me to share it with some changes/improvements:

  • renamed some variables to be a bit more self-explanatory (IMHO)
  • also consider the extra indent characters when calculating the number of lines (if the description is long enough, the indent might cause the need for an extra line)
  • replace the round filter with the ceil filter to calculate the number of rows (no more need for getiCalRows)
  • assigned a more complicated separator to avoid issues with accidental use, and dynamically capture rather than hard-code its size to make sure array creation is correct
  • remove spaces variable and fix it to 1 (indention is not that important, and for longer descriptions this implies smaller size)
  • Remove the indent from the last for loop, so that no unwanted indent or newlines are created in the ICS
  • Our ICS description is based on the content of the event page. So I removed the part that assigns the ‘source description’ and replaced it with event.content. I also added code to replace <p> tags with \n\n which is permitted in ICS files, escape main characters, and remove all other HTML.

Please find here the end result of these updates:

{%- comment %} Create a string that represents the description for an iCal event {% endcomment %}
{%- assign description = event.content | remove_last: '</p>' | replace: '</p>','\n\n' | strip_html | replace:',','\,' | replace:':','\:' | replace:';','\;' | strip_newlines | prepend: 'DESCRIPTION:'  %}

{%- comment %} Determine the number of rows, assuming 74 characters and 1-space indent {% endcomment %}
{%- assign descriptionChars = description.size %} Chars: {{descriptionChars}}
{%- assign descriptionTotalRows = descriptionChars | divided_by: 74.00 | ceil %} Rows (without whitespace): {{descriptionTotalRows}}
{%- assign descriptionChars = descriptionChars | plus: descriptionTotalRows %} Chars including whitespace: {{descriptionChars}}
{%- assign descriptionTotalRows = descriptionChars | divided_by: 74.00 | ceil %} Rows (with whitespace): {{descriptionTotalRows}}
{%- assign descriptionChars = descriptionChars | minus: descriptionTotalRows %} Chars: {{descriptionChars}}

{%- comment %} Define counter for string split starting point {% endcomment %}
{%- assign start = 0 %}

{%- comment %} Define the separator character that will be used to turn the event into an array {% endcomment %}
{%- assign separator = '\||/' %}

{%- comment %} Define empty string (& empty if already used for previous event) that will later contain the description with separators {% endcomment %}
{%- assign descriptionWorker = '' %}

{%- for rowToBe in (1..descriptionTotalRows) %}
  {%- comment %} If the description is only one line, exit the forLoop {% endcomment %}
  {%- if descriptionChars <= 74  %}
    {%- assign descriptionWorker = description %}
    {%- break %}
  {%- comment %} If the description has only more lines, put the description in the worker variable while adding separator and indent  {% endcomment %}
  {%- else %}
    {%- assign descriptionRow = description | slice: start, 73 | prepend: ' ' | append: separator %}
    {%- assign descriptionWorker = descriptionWorker | append: descriptionRow %}
    {%- assign start = start | plus: 73 %}
  {%- endif %}
{%- endfor %}

{%- comment %} With the string-with-separators in place, convert each block (line) into an array {% endcomment %}
{%- if descriptionChars > 74  %}
  {%- assign descriptionWorker = descriptionWorker | lstrip %}
  {%- assign lastChar = descriptionWorker.size | minus: separator.size %}
  {%- assign descriptionWorker = descriptionWorker | slice: 0, lastChar %}
  {%- assign descriptionArray = descriptionWorker | split: separator %}
{%- endif %}

{%- for descriptionLine in descriptionArray %}
{{ descriptionLine }}
{%- endfor %}

Hoping this helps someone else. Code will be put in production here: https://github.com/AntennaPod/antennapod.github.io/pull/192

1 Like

Great! I’m glad you got it working and thank you for posting!

1 Like

Today I went back to this problem and found a way simpler solution: a plugin. That way we can use Ruby functions, which makes the code way shorter and enables us to use that function on multiple lines, without repeating the same liquid code over and over.

Here is the plugin code:

module Jekyll
  module WrapLinesFilter
    def wrap_lines(input_string)
      max_length = 72
      wrapped_string = ""

      while input_string.length > max_length
        line = input_string.slice!(0, max_length)
        line += "\r\n "
        wrapped_string += line
      end

      wrapped_string += input_string

      wrapped_string

    end
  end
end

Liquid::Template.register_filter(Jekyll::WrapLinesFilter)

I put that into _plugins/wrap_lines_filter.rb and then added this to the ics template file snippet:

{{ event.content | strip_html | prepend: "DESCRIPTION:" | normalize_whitespace | wrap_lines }}
{{ event.title | escape | prepend: "SUMMARY:" | wrap_lines }}
{{ event.url | absolute_url | prepend: "URL:" | wrap_lines }}

This creates an ICS file that validates with the icalendar.org validator :tada:

1 Like