1 django-prosemirror
- Version:
0.11.0
- Source:
- Keywords:
Django, Prosemirror, rich-text, editor, document, JSON, WYSIWYG, content editor, text editor, markdown, html
- PythonVersion:
3.12+
Rich-text fields for Django using Prosemirror - a powerful, schema-driven rich text editor.
2 Features
Rich-text editing: Full-featured Prosemirror editor integration with Django (admin) forms
Bidirectional conversion: Seamless conversion between HTML and ProseMirror document format
Configurable schemas: Fine-grained control over allowed content (headings, links, images, tables, etc.)
Native ProseMirror storage: Documents are stored in their native Prosemirror format, preserving structure and enabling programmatic manipulation without HTML parsing
3 Installation
3.1 Requirements
Python 3.12 or above
Django 5.2 or newer
3.2 Install
pip install maykin-django-prosemirror
Add to your Django settings:
INSTALLED_APPS = [
# ... your other apps
'django_prosemirror',
]
4 Usage
4.1 Model Field
Use ProseMirrorModelField in your Django models:
from django.db import models
from django_prosemirror.fields import ProseMirrorModelField
from django_prosemirror.schema import NodeType, MarkType
class BlogPost(models.Model):
title = models.CharField(max_length=200)
# Full-featured rich text content (uses default configuration allowing all node
# and mark types)
content = ProseMirrorModelField()
# Limited schema - only headings and paragraphs with bold text
summary = ProseMirrorModelField(
allowed_node_types=[NodeType.PARAGRAPH, NodeType.HEADING],
allowed_mark_types=[MarkType.STRONG],
null=True,
blank=True
)
# Default document
content_with_prompt = ProseMirrorModelField(
default=lambda: {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{"type": "text", "text": "Start writing..."}]
}
]
}
)
4.2 Accessing Content
Accessing a field returns a ProsemirrorFieldDocument. It holds the raw ProseMirror document structure and lets you read the content as HTML or as the underlying document dict:
post = BlogPost.objects.get(pk=1)
post.content.html # "<h1>Heading</h1><p>Paragraph content...</p>"
post.content.doc # {"type": "doc", "content": [...]}
Use safe_html to render content in templates — Django’s auto-escaping means html would print the tags as literal text:
{{ post.content.safe_html }}
4.3 Setting field values
Fields accept a ProseMirror document dict, an HTML string, None (nullable fields only), or a ProsemirrorFieldDocument. Any other type raises a ValidationError.
# Dict — the native ProseMirror document format
Article.objects.create(content={
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {"level": 1},
"content": [{"type": "text", "text": "My Heading"}]
},
{
"type": "paragraph",
"content": [
{"type": "text", "text": "Some "},
{"type": "text", "marks": [{"type": "strong"}], "text": "bold"},
{"type": "text", "text": " text."}
]
}
]
})
# HTML string — converted to a doc automatically using the field's schema
Article.objects.create(content="<h1>My Heading</h1><p>Some <strong>bold</strong> text.</p>")
# Plain text (no tags) is also accepted — wrapped in a paragraph
Article.objects.create(content="Just plain text")
# Direct assignment on an existing instance works the same way
article = Article.objects.get(pk=1)
article.content = "<p>Replaced via HTML</p>"
article.save()
# Unsupported types raise ValidationError
article.content = ["not", "valid"] # raises ValidationError
Regardless of input type, reading the field always returns a ProsemirrorFieldDocument:
article = Article.objects.get(pk=1)
article.content.html # "<h1>My Heading</h1><p>Some <strong>bold</strong> text.</p>"
article.content.doc # {"type": "doc", "content": [...]}
If you already hold a reference to the ProsemirrorFieldDocument object (e.g. it was passed into a function), you can mutate it in place via the .html or .doc setters — the change is synced back to the model automatically:
def update_content(doc, new_html):
doc.html = new_html # syncs back to the model instance
update_content(post.content, "<p>Updated</p>")
post.save()
ProsemirrorFieldDocument is falsy when the document is None or its content list is empty, and truthy otherwise:
if post.content:
render(post.content.safe_html)
Two convenience methods are also available for clearing the content:
post.content.clear() # reset to an empty document
post.content.nullify() # set to None (field must allow null)
Both update the in-memory model instance only — you must call post.save() to persist the change to the database.
4.4 Form Field
Use ProsemirrorFormField in Django forms:
from django import forms
from django_prosemirror.fields import ProsemirrorFormField
from django_prosemirror.schema import NodeType, MarkType
class BlogPostForm(forms.Form):
title = forms.CharField(max_length=200)
# Full-featured editor (uses default configuration)
content = ProsemirrorFormField()
# Limited to headings and paragraphs with basic formatting
summary = ProsemirrorFormField(
allowed_node_types=[NodeType.PARAGRAPH, NodeType.HEADING],
allowed_mark_types=[MarkType.STRONG, MarkType.ITALIC],
required=False
)
4.5 Schema Configuration
Control exactly what content types are allowed using node and mark types:
from django_prosemirror.schema import NodeType, MarkType
# Available node types
NodeType.PARAGRAPH # Paragraphs (required)
NodeType.HEADING # Headings (h1-h6)
NodeType.BLOCKQUOTE # Quote blocks
NodeType.HORIZONTAL_RULE # Horizontal rules
NodeType.CODE_BLOCK # Code blocks
NodeType.FILER_IMAGE # Images (requires django-filer)
NodeType.HARD_BREAK # Line breaks
NodeType.BULLET_LIST # Bullet lists
NodeType.ORDERED_LIST # Numbered lists
NodeType.LIST_ITEM # List items
NodeType.TABLE # Tables
NodeType.TABLE_ROW # Table rows
NodeType.TABLE_CELL # Table data cells
NodeType.TABLE_HEADER # Table header cells
# Available mark types
MarkType.STRONG # Bold text
MarkType.ITALIC # Italic text (em)
MarkType.UNDERLINE # Underlined text
MarkType.STRIKETHROUGH # Strikethrough text
MarkType.CODE # Inline code
MarkType.LINK # Links
# Custom configurations
BASIC_FORMATTING = {
'allowed_node_types': [NodeType.PARAGRAPH, NodeType.HEADING],
'allowed_mark_types': [MarkType.STRONG, MarkType.ITALIC, MarkType.LINK],
}
BLOG_EDITOR = {
'allowed_node_types': [
NodeType.PARAGRAPH,
NodeType.HEADING,
NodeType.BLOCKQUOTE,
NodeType.IMAGE,
NodeType.BULLET_LIST,
NodeType.ORDERED_LIST,
NodeType.LIST_ITEM,
],
'allowed_mark_types': [
MarkType.STRONG,
MarkType.ITALIC,
MarkType.LINK,
MarkType.CODE,
],
}
TABLE_EDITOR = {
'allowed_node_types': [
NodeType.PARAGRAPH,
NodeType.HEADING,
NodeType.TABLE,
NodeType.TABLE_ROW,
NodeType.TABLE_CELL,
NodeType.TABLE_HEADER,
],
'allowed_mark_types': [MarkType.STRONG, MarkType.ITALIC],
}
# Use in fields
class DocumentModel(models.Model):
blog_content = ProseMirrorModelField(**BLOG_EDITOR)
table_content = ProseMirrorModelField(**TABLE_EDITOR)
4.6 Default Values
Always use callables for default values returning valid ProseMirror documents:
from django_prosemirror.constants import get_empty_doc
class Article(models.Model):
# ✅ Correct: Using a callable
content = ProseMirrorModelField(default=get_empty_doc)
# ❌ Wrong: Static dict (validation error)
# content = ProseMirrorModelField(
# default={"type": "doc", "content": []}
# )
4.7 Django Admin Integration
The field works automatically with Django admin:
from django.contrib import admin
from .models import BlogPost
@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
fields = ['title', 'content', 'summary']
readonly_fields = ['summary'] # Read-only fields render as HTML
# Editable fields: Render the full ProseMirror rich-text editor
# Read-only fields: Render as formatted HTML output
4.8 Frontend Integration
Required Assets: The ProseMirror form fields require both CSS and JavaScript assets to function. These assets are mandatory for any template that renders ProseMirror form fields - without them, the rich text editor will not work.
{% load django_prosemirror %}
<!DOCTYPE html>
<html>
<head>
{% include_django_prosemirror_css %}
{% include_django_prosemirror_js_defer %}
</head>
<body>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
</form>
</body>
</html>
Note: These assets are only required for form rendering (editing). Displaying saved content using {{ post.content.html }} in templates does not require these assets.
4.9 Image uploads
With django-filer installed, the editor uploads images through the endpoints in django_prosemirror.urls. Only staff users can use them, and they need django-filer’s image permissions: “Can add image” to upload an image, “Can change image” to view and edit its details. With a custom FILER_IMAGE_MODEL, the permissions are that model’s.
Uploading and editing images needs a CSRF token. The editor sends the one from the page’s {% csrf_token %}, so render it on pages with the editor, for example in the form around it; the Django admin already does. Without it, the editor falls back to the CSRF cookie with its default name, csrftoken.
Images uploaded through the editor are checked before they are stored:
Only JPEG, PNG, GIF and WebP files are accepted (.jpg, .jpeg, .png, .gif, .webp), and the file content must be an image of the format its extension says. SVG is not accepted.
The upload must not exceed max_image_upload_size bytes (default 10 MiB, None for no limit):
DJANGO_PROSEMIRROR = { "max_image_upload_size": 5 * 1024 * 1024, }django-filer’s upload policy applies, as for uploads through the filer admin: FILER_MIME_TYPE_WHITELIST and the file validators configured with FILER_ADD_FILE_VALIDATORS.
4.10 Checking stored images
Before 0.11.0, the image upload endpoint accepted any file. If you ran an earlier version with django-filer, unsafe files may be stored as filer images, e.g. SVGs with scripts or HTML with an image extension. Check them with:
python manage.py check_filer_images --hide-ok --base-url https://example.com
Each image gets a verdict:
SUSPICIOUS: review it in the admin, and delete it if it can’t be explained. Don’t open the file itself, from the admin or the report: opened directly in a browser, its scripts may run. The file isn’t an image, an SVG contains possible scripts, the file would be served with a non-image content type, or it fails another check.
UNCHECKED: the file exists but couldn’t be read. Run the command again once it can.
NOTE: wouldn’t be accepted as an upload today, but can’t run code, e.g. an SVG without scripts or an image with the wrong extension. An image that is otherwise OK also gets a note if its uploader isn’t a current staff member (an active user with is_staff), since only staff can upload today.
OK: passes the upload validation and was uploaded by a current staff member.
The table lists every image, worst first, with whether the uploader is a current staff member and a link to each image’s admin page. --hide-ok leaves out the images that are OK, to list only those that need attention. --format csv writes the report as CSV with all details, for use in a spreadsheet, and --output writes the report to a file. --base-url makes the links absolute; it defaults to the current site’s domain if django.contrib.sites is installed. The command exits with status 3 if any image is suspicious, else with status 4 if any image is unchecked. Status 1 means the command failed (for example, the database couldn’t be reached), and status 2 that its arguments are invalid.
Add your own checks with --validator path.to.check: a function that takes the filer image and its file content (bytes) and raises ValidationError if the image fails, which makes the image suspicious. Any other exception stops the command with status 1.
5 Data migrations
Corrupt ProseMirror field data can end up in the database whenever code bypasses the field’s validation — for example, a bulk_create or update() call that writes raw values directly, a third-party library that writes to the column without going through the descriptor, or a data import that predates the field being introduced. django_prosemirror provides helpers in django_prosemirror.migration_utils to audit and repair affected rows safely. All helpers bypass the field descriptor via .values() and .update(), so they work even when corrupt rows would otherwise raise ValidationError on normal access.
5.1 Auditing
Use the iter_* functions to inspect data without modifying it:
from django_prosemirror.migration_utils import (
iter_corrupt_prosemirror_rows,
iter_schema_invalid_prosemirror_rows,
)
# Wrong type or shape (strings, lists, numbers, malformed dicts)
for pk, raw in iter_corrupt_prosemirror_rows(MyModel, "body"):
print(f"pk={pk}: corrupt value {raw!r}")
# Correct shape but fails schema validation for that field
for pk, raw in iter_schema_invalid_prosemirror_rows(MyModel, "body"):
print(f"pk={pk}: schema-invalid doc {raw!r}")
These two functions cover disjoint sets: a row will appear in at most one of them.
5.2 Repairing
Three repair functions are available, each returning a list[RepairRecord] for logging:
from django_prosemirror.migration_utils import (
repair_prosemirror_html_strings,
nullify_corrupt_prosemirror_rows,
clear_corrupt_prosemirror_rows,
)
# Convert corrupt HTML strings to proper doc dicts
records = repair_prosemirror_html_strings(MyModel, "body")
# Set corrupt values to NULL (nullable fields only — raises ValueError otherwise)
records = nullify_corrupt_prosemirror_rows(MyModel, "body")
# Set corrupt values to an empty doc (works on any field)
records = clear_corrupt_prosemirror_rows(MyModel, "body")
for r in records:
print(f"pk={r.pk}: {r.original!r} → {r.repaired!r}")
5.3 Example data migration
A common case is a field that previously stored raw HTML strings. Use repair_prosemirror_html_strings to convert them in place:
from django_prosemirror.migration_utils import repair_prosemirror_html_strings
def migrate(apps, schema_editor):
MyModel = apps.get_model("myapp", "MyModel")
records = repair_prosemirror_html_strings(MyModel, "body")
for r in records:
print(f"Repaired pk={r.pk}")
print(f"{len(records)} row(s) repaired")
class Migration(migrations.Migration):
dependencies = [("myapp", "0001_initial")]
operations = [migrations.RunPython(migrate, migrations.RunPython.noop)]
The schema used for conversion is derived automatically from the field definition on the historical model, so no manual schema configuration is needed.
For non-string corrupt values (integers, lists, malformed dicts) there is no automatic conversion. If you know what shape the corrupt data takes you can fix it yourself using iter_corrupt_prosemirror_rows:
from django_prosemirror.migration_utils import iter_corrupt_prosemirror_rows
def migrate(apps, schema_editor):
MyModel = apps.get_model("myapp", "MyModel")
for pk, raw in iter_corrupt_prosemirror_rows(MyModel, "body"):
fixed = my_conversion(raw) # your own logic here
MyModel.objects.filter(pk=pk).update(body=fixed)
class Migration(migrations.Migration):
dependencies = [("myapp", "0001_initial")]
operations = [migrations.RunPython(migrate, migrations.RunPython.noop)]
If the values are genuinely unrecoverable, fall back to clear_corrupt_prosemirror_rows to reset them to an empty document, or nullify_corrupt_prosemirror_rows if the field allows null:
from django_prosemirror.migration_utils import (
clear_corrupt_prosemirror_rows,
nullify_corrupt_prosemirror_rows,
)
def migrate(apps, schema_editor):
MyModel = apps.get_model("myapp", "MyModel")
# For non-nullable fields
clear_corrupt_prosemirror_rows(MyModel, "body")
# For nullable fields
nullify_corrupt_prosemirror_rows(MyModel, "summary")
class Migration(migrations.Migration):
dependencies = [("myapp", "0001_initial")]
operations = [migrations.RunPython(migrate, migrations.RunPython.noop)]
5.4 Repairing unsafe URLs
django_prosemirror validates that link href and image src values use a safe URL scheme (http, https, mailto, tel, or a relative URL) — see django_prosemirror.sanitize. Rows written before this validation existed may still hold a javascript: or data: URL; these aren’t corrupt (the shape is fine), but they will fail validation the next time anything saves the row, even an edit to an unrelated field. Use strip_unsafe_prosemirror_urls to strip the offending marks/nodes:
from django_prosemirror.migration_utils import strip_unsafe_prosemirror_urls
def migrate(apps, schema_editor):
MyModel = apps.get_model("myapp", "MyModel")
records = strip_unsafe_prosemirror_urls(MyModel, "body")
for r in records:
print(f"Repaired pk={r.pk}")
print(f"{len(records)} row(s) repaired")
class Migration(migrations.Migration):
dependencies = [("myapp", "0001_initial")]
operations = [migrations.RunPython(migrate, migrations.RunPython.noop)]
6 Local development
Requirements for development:
Node.js (for building frontend assets)
All runtime requirements listed above
Setup for development:
python -mvirtualenv .venv
source .venv/bin/activate
# Install Python package in development mode
pip install -e .[tests,docs,release]
# Install Node.js dependencies
npm install
# Build frontend assets (after cloning and after every frontend change)
npm run build
The built assets are not committed to git, so without npm run build the editor has no JavaScript or CSS. The PyPI package does include them.
When running management commands via django-admin, make sure to add the root directory to the python path (or use python -m django <command>):
export PYTHONPATH=. DJANGO_SETTINGS_MODULE=testapp.settings
django-admin migrate
django-admin createsuperuser # optional
django-admin runserver
Metadata
Release files for maykin-django-prosemirror 0.11.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| maykin_django_prosemirror-0.11.0.tar.gz | 661.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| maykin_django_prosemirror-0.11.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.3 MB
Release files / maykin_django_prosemirror-0.11.0.tar.gz
| Download URL | maykin_django_prosemirror-0.11.0.tar.gz |
|---|---|
| Size | 661.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
47bf91b67edd087d0c867ea4ad0bf364178fe001a77cfa672ba271cbfc72b1b1
|
|
BLAKE2b-256 checksum How to use checksums |
5efc722aca1ec53fa59ebabf55895ff7e67b97a69a502c0326e48c0337b2e807
|
| 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 Oct 8, 2026.
Transparency logRelease files / maykin_django_prosemirror-0.11.0-py3-none-any.whl
| Download URL | maykin_django_prosemirror-0.11.0-py3-none-any.whl |
|---|---|
| Size | 640.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0040eb872ce2d200a90fdb089112e27475ae8c0e2b47c4de2a5fcfb00ba43d22
|
|
BLAKE2b-256 checksum How to use checksums |
f2746a8471f91e8da4390670c9a0a1f4b108a06fd23a59db78553adb3c64567e
|
| 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 Oct 8, 2026.
Transparency log