django-rmeditor
A customizable, self-hosted rich text editor for Django — a drop-in TinyMCE alternative with extra features and full customization, and no CDN, API key, or usage limits.
A lightweight, self-hosted rich text editor for Django. It outputs plain HTML,
so it is a drop-in replacement for a TextField / tinymce HTMLField with no data
migration. No CDN, no API key, no branding, no word/usage limits.
- Pure vanilla JS (no jQuery, no build step).
- Auto-enhances any
<textarea class="rmeditor">. - Existing content is loaded as-is (HTML in / HTML out).
- Configurable toolbar per field.
- Paste cleaning (strips Word/Office junk) + basic XSS sanitize.
- Works with Django forms, the admin, or hand-written templates.
Install
pip install django-rmeditor
# settings.py
INSTALLED_APPS = [
...,
"rmeditor",
]
Make sure django.contrib.staticfiles is set up (it is by default) and run
collectstatic in production.
Usage
1. Hand-written templates (no form/widget)
{% load rmeditor %}
{% rmeditor_media %} {# include once per page, e.g. in your base template #}
<form method="post">
{% csrf_token %}
<textarea name="body" class="rmeditor"
data-tools="format fontsize | bold italic underline | bullist numlist | link image | removeformat">
{{ object.body }}
</textarea>
<button type="submit">Save</button>
</form>
The textarea stays in the form (hidden) and always holds the current HTML, so your
existing view code that reads request.POST["body"] keeps working unchanged.
Autochange — enhance every textarea (no class needed)
To convert all textareas on a page into editors without touching each one
(like tinymce's selector:'textarea'), enable autochange:
{% load rmeditor %}
{% rmeditor_media auto="textarea" %} {# every <textarea> becomes an editor #}
or from JS:
<script src="/static/rmeditor/js/rmeditor.js"></script>
<script>RMEditor.auto("textarea");</script> <!-- or any CSS selector -->
or globally before the script loads:
<script>window.RMEDITOR_AUTO = "textarea.rich";</script>
Opt a textarea out of autochange with class="no-rmeditor" or data-rmeditor="off".
This makes replacing tinymce a one-line change: swap the tinymce script/init for
{% rmeditor_media auto="textarea" %}.
2. Model field
from django.db import models
from rmeditor.fields import RichTextField
class Article(models.Model):
body = RichTextField(blank=True, default="")
RichTextField is a TextField subclass — same column, no data difference. Switching
an existing TextField/HTMLField to it needs only a no-op migration.
3. Form widget
from django import forms
from rmeditor.widgets import RichTextWidget
class ArticleForm(forms.ModelForm):
class Meta:
model = Article
fields = ["body"]
widgets = {"body": RichTextWidget(tools="bold italic link")}
Toolbar tokens
format (paragraph/H1/H2/H3 dropdown), fontsize (px-based dropdown), bold, italic,
underline, strike, forecolor, bullist, numlist, indent, outdent, alignleft,
aligncenter, alignright, justify, link, unlink, image, table, rowadd,
rowdel, coladd, coldel, removeformat, source (view HTML), undo, redo.
Table tokens: table inserts a table (prompts for rows,columns); rowadd/rowdel
add/delete a row and coladd/coldel add/delete a column relative to the cell the
caret is in.
Use | for a separator. Set per field with data-tools="..." or on the widget with
RichTextWidget(tools="...").
Pasting from ChatGPT, Google Docs and Word
Paste is cleaned automatically. Styles, classes, ids and Office junk are stripped, and the structure is tidied so the result matches text typed directly into the editor rather than carrying the source document's layout:
- the wrapper element around the whole paste is unwrapped
<div>s used as paragraphs become<p>;<div>s that only wrap block content are unwrapped<b>/<i>become<strong>/<em>- empty blocks left over from unwrapping are dropped
Heading levels are kept exactly as copied. What changes is that the wrapper and the stray divs stop interfering with spacing, so the result reads the way it did in the source instead of collapsing into one block.
If pasted content needs to sit under a page's own h1, headings can be shifted
down instead:
<!-- source h1 becomes h3, h2 becomes h4, and so on -->
<script src="{% static 'rmeditor/js/rmeditor.js' %}" data-paste-headings="demote" defer></script>
<!-- headings become bold paragraphs -->
<script src="{% static 'rmeditor/js/rmeditor.js' %}" data-paste-headings="flatten" defer></script>
Or set window.RMEDITOR_PASTE_HEADINGS = "demote" before the script loads.
Only paste is affected — content already stored, and anything typed or formatted with the toolbar, is left exactly as-is.
JavaScript API
RMEditor.get(el) // instance for a textarea node or its id ("" if none)
RMEditor.getHTML(el) // current HTML
RMEditor.setHTML(el, html) // replace content
RMEditor.getText(el) // plain text, trimmed
RMEditor.cleanPaste(html) // run the paste cleaner over a string (no paste)
RMEditor.syncAll() // flush every editor into its textarea
el may be the textarea DOM node or its id string.
Migrating from tinymce
- Replace the tinymce script/
tinymce.init(...)with{% rmeditor_media %}and addclass="rmeditor"to the textareas. tinymce.get("x").setContent(html)→RMEditor.setHTML("x", html)tinymce.get("x").getContent({format:"text"})→RMEditor.getText("x")
Storage stays HTML, so rendered pages ({{ field|safe }}), PDFs, and API/mobile
consumers are unaffected.
Notes / limits (v0.1)
- Uses
document.execCommand(deprecated but supported in all current browsers). - Tables support insert plus add/remove row and column. Cell merging/splitting and media (video/embed) editing are out of scope; existing such content is preserved and still renders.
- Client-side sanitize is a basic guard. For untrusted authors, also sanitize on the
server (e.g.
bleach) before rendering with|safe.
License
MIT
Release files for django-rmeditor 0.1.13
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_rmeditor-0.1.13.tar.gz | 24.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_rmeditor-0.1.13-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 47.1 kB
Release files / django_rmeditor-0.1.13.tar.gz
| Download URL | django_rmeditor-0.1.13.tar.gz |
|---|---|
| Size | 24.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3183cbe56bc7c1e4e2c90cc15cb3e8ac3c8e2676cade966df66d2b7838b085d3
|
|
BLAKE2b-256 checksum How to use checksums |
736442134962f3d2b96b4af6f5b4e9beee20ba93a9f590cba41739c9ca3e7cce
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.
Transparency logRelease files / django_rmeditor-0.1.13-py3-none-any.whl
| Download URL | django_rmeditor-0.1.13-py3-none-any.whl |
|---|---|
| Size | 22.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0765de8731263a22aa7720dea78ee6fa4bffb042190a2ae350be32baa6e18768
|
|
BLAKE2b-256 checksum How to use checksums |
244c92cadcf0b526b971897907bf584dbb3e1d41a4f6f1b8c62394eec059e61b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.
Transparency log