Content: Blog

Tutorials, Technical articles, Addons

See how easy it is to extend django CMS!

Fabian Braun

Aug. 18, 2023

Learn how to create a django CMS plugin that allows rendering of scientific formulae using KaTeX.

I was giving a demonstration of django CMS the other day when a person asked: "Can you create scientific formulae with django CMS? My users love KaTeX!" KaTeX is a library allowing to show formulae written in Donald Kuth's TeX typesetting system on websites. 

Well, I thought, this is a great example why django CMS is so easily extensible:

  • The heavy lifting is done by any arbitrarily complex (business) logic already created (KaTeX in our example).
  • The CMS needs to be able to use the results for displaying the resulting content (this we will create in this blog post).

I have bundled the result of these thoughts into a small package called djangocms-katex. It is available on GitHub and PyPI.

Overview: What do we need?

First, we'll need a copy of KaTeX. It is available from CDN or its GitHub repository and consists of a JavaScript library, a CSS library, and specific mathematical fonts which it uses.

To display a formula, KaTeX needs its source code (written in LaTeX, e.g., something like c = \sqrt{a^2 + b^2}. As LaTeX, KaTeX can render a formula in an inline style or a display style. The inline style will choose smaller mathematical symbols so that a formula has the chance of fitting into the text of a paragraph. The display style will show a full-sized formula, which will occupy a full-with line on the screen.

We'll be creating a little app, called djangocms_katex which will reside in a folder of the same name. Once you add it to your project's INSTALLED_APPS you will be able to use it with your django CMS projects.

djangocms_katex
├── cms_plugins.py
├── forms.py
├── migrations
├── models.py
├── static
│   └── djangocms_katex
│       └── admin
│           ├── css
│           │   └── preview.css
│           └── js
│               └── preview.js
└── templates
    └── djangocms_katex
        ├── admin
        │   └── katex_widget.html
        └── formula.html

The model

To create a plugin, its model will have to have two fields, one holding the formula source code, the other the display mode.

The plugin class will be called KaTex and have two fields: katex and katex_display_style, a TextField and a SmallIntegerField, respectively. I also added a little get_short_description method. django CMS uses it to display some content of the plugin in the plug-in tree to allow the users to more quickly reconcile what they see on the page and in the structure board.

# djangocms_katex/models.py
#

from cms.models import CMSPlugin
from django.db import models
from django.utils.functional import lazy
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _


class KaTex(CMSPlugin):

    class Meta:
        verbose_name = _("KaTeX Formula")

    katex = models.TextField(
        verbose_name=_("Formula"),
        blank=False,
        help_text=lazy(mark_safe, str)(
            _('Read more about KaTeX formulae in <a href="{link}" target="_blank">its documentation</a>')
            .format(link="https://katex.org")
        )  # A lazy string which will be marked safe
    )
    katex_display_style = models.SmallIntegerField(
        verbose_name=_("Style"),
        choices=((0, _("Inline style")), (1, _("Display style"))),
        null=False,
        default=1,
        help_text=_("Switch between inline and display style"),
    )

    def get_short_description(self):
        """Provides a short description shown in the structure board. This will show the
        formula's source text."""
        return f"({self.katex})"

The CMS plugin

django CMS will look for plugins in the app's cms_plugins.py. This is also pretty forward. The plugin is a subclass of cms.pluginbase.CMSPluginBase and needs to define 

  • a template to be rendered as a snippet at the place of the plugin
  • optionally, a form for editing the plugin (we want to use a custom form to allow for a preview of the formula entered)
  • a render method that provides the template with the necessary context.

By adding the attribute text_enabled = True the plugin can be added to text plugins of djangocms-text-ckeditor. This is useful for inline formulae.

The `render` method adds two fields to the context:

  1. tag_type: The defines the enclosing tag for the formula in HTML. It will be a <div> for display-style formulae and a <span> for inline formulae.
  2. options: This is a JSON string defining the options for the KaTeX JavaScript renderer (which will be called from the template). We do not want errors to be raised but displayed instead. Also, the formula style (display/inline) is passed.
# djangocms_katex/cms_plugins.py
#

import json

from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from django.utils.translation import gettext_lazy as _

from . import forms, models


@plugin_pool.register_plugin
class KaTexPlugin(CMSPluginBase):
    """
    """

    name = _("KaTeX Formula")
    model = models.KaTex
    form = forms.KaTexForm
    render_template = "djangocms_katex/formula.html"
    text_enabled = True

    def render(self, context, instance, placeholder):
        context["options"] = json.dumps({
            "throwOnError": False,
            "displayMode": instance.katex_display_style == 1,
        })
        context["tag_type"] = "div" if instance.katex_display_style else "span"
        return super().render(context, instance, placeholder)

The plugin template

The render template consists of three parts: The HTML section, the JavaScript section, and the CSS section.

The HTML code

The HTML code pretty simple: It creates a tag of type {{ tag_type }} with two data attributes. data-katex-rendered is a flag (initially false) which will be switched to true once the formula has been rendered by the JavaScript library. This is to avoid double-rendering: Once the template is rendered, its source code is not available any more. data-katex-options contains the options for the KaTeX renderer as a JSON string.

The formula's source code goes into the tag.

As an example, this might render <div data-katex-rendered="false" data-katex-options='{"throwOnError": false, "displayMode": false}'>F = m \cdot a</div>.

KaTeX's JavaScript and CSS code

The next two blocks load KaTeX's JavaScript and CSS from their CDN. Not much to see here. Both are loaded using django-sekizai, a package that (among other things) is used by django CSS to collect required CSS and JavaScript and ensure that it is only put on the page's HTML once.

Plugin's JavaScript code

The last JavaScript block is specific for the plugin. It creates a storage for KaTeX macros which is valid on the whole page and allows sharing macros between different plugin instances on one page (window.djangocms_katex_macros).

It proceeds to define a render function (in a closure to avoid polluting the global namespace) which looks for unrendered KaTex formulae on the page (identified by data-katex-rendered="false"). Rendering itself - the real heavy lifting - is taken over by the KaTeX library.

Finally, the render function is attached to the DOMContentLoaded event so that all formulae are rendered once the full page is loaded.

Since django CMS only swaps snippets upon editing a plugin, the render function is also attached to django CMS cms-content-refresh event if the CMS toobar is active. This ensures that the formulae is rerendered if the content is changed.

{# templates/djangocms_katex/formula.html #}
{% load sekizai_tags static %}
{% spaceless %}
    <{{ tag_type }} data-katex-rendered="false" data-options='{{ options }}'>{{ instance.katex }}
    </{{ tag_type }}>
{% endspaceless %}
{% addtoblock "css" %}<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css" integrity="sha384-GvrOXuhMATgEsSwCs4smul74iXGOixntILdUW9XmUC6+HX0sLNAK3q71HotJqlAn" crossorigin="anonymous">{% endaddtoblock %}
{% addtoblock "js" %}<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js" integrity="sha384-cpW21h6RZv/phavutF+AuVYrr+dA8xD9zs6FwLpaCct6O9ctzYFfFr4dgmgccOTx" crossorigin="anonymous"></script>{% endaddtoblock %}
{% addtoblock "js" %}
  <script>
      window.djangocms_katex_macros = window.djangocms_katex_macros || {};
      (function () {
        function render()
        {
          for (const element of document.querySelectorAll('[data-katex-rendered="false"]')) {
              let options = JSON.parse(element.dataset.options);
              options.macros = djangocms_katex_macros;  // Persistent macros on a page
              try {
                  katex.render(element.textContent, element, options);
                  element.dataset.katexRendered = true;
              } catch (e) {
                  console.error(e);
              }
          };
        };
        render();
        document.addEventListener("DOMContentLoaded", render);
        {% if request.toolbar %}
            CMS.$(window).on('cms-content-refresh', render);
        {% endif %}
      })();
  </script>
{% endaddtoblock %}

The icing: Change form with preview

While the code so far is sufficient to run the plugin, I would like to add a custom change form for the plugin to include a preview. It is pretty easy to make mistakes when entering a formula. This will be immediately apparent in the preview.

To achieve this, we define a custom change form KaTexForm which loads the KaTeX resources, the ACE editor for easier editing of the formula's source code, and our own JavaScript code and styles preview.js and preview.css

Finally, it sets the form widget for the source code field katex to a custom widget (KaTexInput) which is a subclass of Django's Textarea widget. This allows to define a custom widget template which can carry the preview field.

# forms.py
#

from django import forms
from django.conf import settings
from django.forms import ModelForm

from . import models


class KaTexInput(forms.Textarea):
    def __init__(self, *args, **kwargs):
        self.textarea_template = self.template_name
        self.template_name = "djangocms_katex/admin/katex_widget.html"
        super().__init__(*args, **kwargs)

    def get_context(self, name, value, attrs):
        context = super().get_context(name, value, attrs)
        context["widget"]["textarea_template"] = self.textarea_template
        return context



class KaTexForm(ModelForm):
    class Media:
        js = (
            "djangocms_katex/admin/js/preview.js",
            "https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js",
            "https://cdnjs.cloudflare.com/ajax/libs/ace/1.9.6/ace.js"
        )
        css = {"all": (
            "djangocms_katex/admin/css/preview.css",
            "https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css",
        )}

    class Meta:
        model = models.KaTex
        widgets = {
            "katex": KaTexInput,
        }
        fields = "__all__"

The KaTeX widget's HTML code is straightforward: The widget adds the reference to the original textarea template to the context and all we need to do is to create the HTML code for the preview field, a div for the ACE editor, and then include the code for the textarea widget which will not be visible.

The preview field is a div containing a small title ("Preview"), another div to contain the actual preview and a small field for potential error messages. Initially, the preview div and the error message are empty.

{# templates/djangocms_katex/admin/katex_widget.html #}
{% load i18n %}
<div class="katex-preview">
    <small>{% translate "Preview" %}</small>
    <div id="preview_area_id">
    </div>
    <small class="error" id="katex-error"></small>
</div>
<div id="katex-ace-editor"></div>
{% include widget.textarea_template %}

To create a great user experience, the preview.js file does two things

  1. It loads the ACE code editor for editing the formula's source code
  2. It updates the preview field as soon as the code in the editor changes.

First, all relevant elements of the form which need to interact are identified by their element IDs. Then the ACE container div is prepared with a few styles which are taken from the original textarea which in turn is hidden. The editor is initialized in LaTeX mode.

Now, the function update_formula takes the input from the editor and renders the formula into its preview div (or span, if it is an inline formula). Errors are caught and displayed, as well as a potentially partially rendered version of a formula with an error. The error message of KaTeX is shown in the preview error span. (Unfortunately, this is not localized, and error messages will pop up in English only.)

Finally, the update_formula function is attached to the appropriate change events and called initally.

// static/djangocms_katex/admin/js/preview.js
//

document.addEventListener("DOMContentLoaded", function () {
    'use strict';

    const display_style = document.getElementById('id_katex_display_style');
    const preview_area = document.getElementById('preview_area_id');
    const input_field = document.getElementById('id_katex');
    const error_field = document.getElementById('katex-error');
    const ace_field = document.getElementById('katex-ace-editor');

    // Prepare ace field and hide textarea
    ace_field.style.height = window.getComputedStyle(input_field).height;
    ace_field.style.width = "100%";
    input_field.style.display = "None";

    // init editor with settings
    var editor = ace.edit(ace_field, {
        mode: 'ace/mode/latex',
        fontSize: '14px'
    });
    editor.setTheme('ace/theme/github');
    editor.getSession().setValue(input_field.value);

    function update_formula() {
        const display_type = display_style.value !== '0';
        preview_area.innerHTML = (display_type ? '<div></div>' : '<span></span>');
        input_field.value = editor.getSession().getValue();
        try {
            katex.render(input_field.value, preview_area.children[0], {
                throwOnError: true,
                displayMode: display_type
            });
            error_field.textContent = '';
        } catch(e) {
           katex.render(input_field.value, preview_area.children[0], {
                throwOnError: false,
                displayMode: display_type
            });
            let message = String(e);
            if (message.startsWith('ParseError: ')) {
                message = message.slice('ParseError: '.length);
            }
            error_field.textContent = message;
            console.error(e);
        }
    }
    editor.getSession().addEventListener('change', update_formula);
    display_style.addEventListener('change', update_formula);
    update_formula();
    editor.focus();
 });

For the preview to have a somewhat decent look, only a few lines of CSS are necessary.

/* static/djangocms_katex/admin/css/preview.css */

.katex-preview {
    width: 100%;
    padding-top: 0.5em;
    padding-bottom: 1em;
    background: var(--dca-gray-lightest, var(--darkened-bg, #f2f2f2));
    border: solid 1px var(--dca-gray-lighter, var(--border-color, #ddd));
    border-radius: 3px;
    min-height: 4em;
}

div#preview_area_id > span, div#preview_area_id > div {
    margin: 0 15px;
}

.katex-preview small {
    color: var(--dca-gray, var(--body-quiet-color, #666));
    margin-bottom: 1em;
}

small.error {
    color: var(--error-fg);
}

#content > h2 {
    display: none;
}

The final touch

Now, before testing, you will need to create the folder migrations in the djangocms_katex directory and run python manage.py makemigrations and python manage.py migrate to create and run the needed migrations files for the new LaTex model.

Also, do not forget to add djangocms_katex to your INSTALLED_APPS. Only then django CMS will be able to know about it!

To sum it up...

For most of django CMS' plugins, the complexity of creating is quite comparable to this example. While in other cases not JavaScript libraries do most of the work, it is quite often the case that the plugins only need to access Django models or call an API from another Django app. 

While we have created less than 75 lines of Python code and less than 55 lines of JavaScript code, this included

  • Neat equations for your web page
  • A custom change form for the plugin
  • A live preview functionality for the editor

The result of this tutorial are available on GitHub and PyPi

blog comments powered by Disqus

Do you want to test django CMS?

Try django CMS