Files
old-saburly-wagtail-web/env/lib/python3.10/site-packages/wagtail/admin/tests/test_contentstate.py
2024-08-27 20:33:44 +02:00

2086 lines
71 KiB
Python

import json
from unittest.mock import patch
from django.test import TestCase
from draftjs_exporter.dom import DOM
from draftjs_exporter.html import HTML as HTMLExporter
from wagtail.admin.rich_text.converters.contentstate import (
ContentstateConverter,
persist_key_for_block,
)
from wagtail.embeds.models import Embed
def content_state_equal(v1, v2, match_keys=False):
"Test whether two contentState structures are equal, ignoring 'key' properties if match_keys=False"
if type(v1) != type(v2):
return False
if isinstance(v1, dict):
if set(v1.keys()) != set(v2.keys()):
return False
return all(
(k == "key" and not match_keys)
or content_state_equal(v, v2[k], match_keys=match_keys)
for k, v in v1.items()
)
elif isinstance(v1, list):
if len(v1) != len(v2):
return False
return all(
content_state_equal(a, b, match_keys=match_keys) for a, b in zip(v1, v2)
)
else:
return v1 == v2
class TestHtmlToContentState(TestCase):
fixtures = ["test.json"]
def assertContentStateEqual(self, v1, v2, match_keys=False):
"Assert that two contentState structures are equal, ignoring 'key' properties if match_keys is False"
self.assertTrue(
content_state_equal(v1, v2, match_keys=match_keys),
"%s does not match %s"
% (json.dumps(v1, indent=4), json.dumps(v2, indent=4)),
)
def test_paragraphs(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
<p data-block-key='00000'>Hello world!</p>
<p data-block-key='00001'>Goodbye world!</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Hello world!",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Goodbye world!",
"depth": 0,
"type": "unstyled",
"key": "00001",
"entityRanges": [],
},
],
},
match_keys=True,
)
def test_unknown_block_becomes_paragraph(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
<foo>Hello world!</foo>
<foo>I said hello world!</foo>
<p>Goodbye world!</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Hello world!",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "I said hello world!",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Goodbye world!",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_bare_text_becomes_paragraph(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
before
<p>paragraph</p>
between
<p>paragraph</p>
after
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "before",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "paragraph",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "between",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "paragraph",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "after",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_ignore_unrecognised_tags_in_blocks(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
<p>Hello <foo>frabjuous</foo> world!</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Hello frabjuous world!",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_inline_styles(self):
converter = ContentstateConverter(features=["bold", "italic"])
result = json.loads(
converter.from_database_format(
"""
<p>You <b>do <em>not</em> talk</b> about Fight Club.</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [
{"offset": 4, "length": 11, "style": "BOLD"},
{"offset": 7, "length": 3, "style": "ITALIC"},
],
"text": "You do not talk about Fight Club.",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_inline_styles_at_top_level(self):
converter = ContentstateConverter(features=["bold", "italic"])
result = json.loads(
converter.from_database_format(
"""
You <b>do <em>not</em> talk</b> about Fight Club.
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [
{"offset": 4, "length": 11, "style": "BOLD"},
{"offset": 7, "length": 3, "style": "ITALIC"},
],
"text": "You do not talk about Fight Club.",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_inline_styles_at_start_of_bare_block(self):
converter = ContentstateConverter(features=["bold", "italic"])
result = json.loads(
converter.from_database_format(
"""<b>Seriously</b>, stop talking about <i>Fight Club</i> already."""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [
{"offset": 0, "length": 9, "style": "BOLD"},
{"offset": 30, "length": 10, "style": "ITALIC"},
],
"text": "Seriously, stop talking about Fight Club already.",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_inline_styles_depend_on_features(self):
converter = ContentstateConverter(features=["italic", "just-made-it-up"])
result = json.loads(
converter.from_database_format(
"""
<p>You <b>do <em>not</em> talk</b> about Fight Club.</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [
{"offset": 7, "length": 3, "style": "ITALIC"}
],
"text": "You do not talk about Fight Club.",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_ordered_list(self):
converter = ContentstateConverter(features=["h1", "ol", "bold", "italic"])
result = json.loads(
converter.from_database_format(
"""
<h1 data-block-key='00000'>The rules of Fight Club</h1>
<ol>
<li data-block-key='00001'>You do not talk about Fight Club.</li>
<li data-block-key='00002'>You <b>do <em>not</em> talk</b> about Fight Club.</li>
</ol>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "The rules of Fight Club",
"depth": 0,
"type": "header-one",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "You do not talk about Fight Club.",
"depth": 0,
"type": "ordered-list-item",
"key": "00001",
"entityRanges": [],
},
{
"inlineStyleRanges": [
{"offset": 4, "length": 11, "style": "BOLD"},
{"offset": 7, "length": 3, "style": "ITALIC"},
],
"text": "You do not talk about Fight Club.",
"depth": 0,
"type": "ordered-list-item",
"key": "00002",
"entityRanges": [],
},
],
},
match_keys=True,
)
def test_nested_list(self):
converter = ContentstateConverter(features=["h1", "ul"])
result = json.loads(
converter.from_database_format(
"""
<h1 data-block-key='00000'>Shopping list</h1>
<ul>
<li data-block-key='00001'>Milk</li>
<li data-block-key='00002'>
Flour
<ul>
<li data-block-key='00003'>Plain</li>
<li data-block-key='00004'>Self-raising</li>
</ul>
</li>
<li data-block-key='00005'>Eggs</li>
</ul>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Shopping list",
"depth": 0,
"type": "header-one",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Milk",
"depth": 0,
"type": "unordered-list-item",
"key": "00001",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Flour",
"depth": 0,
"type": "unordered-list-item",
"key": "00002",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Plain",
"depth": 1,
"type": "unordered-list-item",
"key": "00003",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Self-raising",
"depth": 1,
"type": "unordered-list-item",
"key": "00004",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Eggs",
"depth": 0,
"type": "unordered-list-item",
"key": "00005",
"entityRanges": [],
},
],
},
match_keys=True,
)
def test_external_link(self):
converter = ContentstateConverter(features=["link"])
result = json.loads(
converter.from_database_format(
"""
<p>an <a href="http://wagtail.org">external</a> link</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "http://wagtail.org"},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an external link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 3, "length": 8, "key": 0}],
},
],
},
)
def test_link_in_bare_text(self):
converter = ContentstateConverter(features=["link"])
result = json.loads(
converter.from_database_format(
"""an <a href="http://wagtail.org">external</a> link"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "http://wagtail.org"},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an external link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 3, "length": 8, "key": 0}],
},
],
},
)
def test_link_at_start_of_bare_text(self):
converter = ContentstateConverter(features=["link"])
result = json.loads(
converter.from_database_format(
"""<a href="http://wagtail.org">an external link</a> and <a href="http://torchbox.com">another</a>"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "http://wagtail.org"},
},
"1": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "http://torchbox.com"},
},
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an external link and another",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [
{"offset": 0, "length": 16, "key": 0},
{"offset": 21, "length": 7, "key": 1},
],
},
],
},
)
def test_page_link(self):
converter = ContentstateConverter(features=["link"])
result = json.loads(
converter.from_database_format(
"""
<p>an <a linktype="page" id="3">internal</a> link</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"id": 3, "url": "/events/", "parentId": 2},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an internal link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 3, "length": 8, "key": 0}],
},
],
},
)
def test_broken_page_link(self):
converter = ContentstateConverter(features=["link"])
result = json.loads(
converter.from_database_format(
"""
<p>an <a linktype="page" id="9999">internal</a> link</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {
"id": 9999,
"url": None,
"parentId": None,
},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an internal link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 3, "length": 8, "key": 0}],
},
],
},
)
def test_link_to_root_page(self):
converter = ContentstateConverter(features=["link"])
result = json.loads(
converter.from_database_format(
"""
<p>an <a linktype="page" id="1">internal</a> link</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"id": 1, "url": None, "parentId": None},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an internal link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 3, "length": 8, "key": 0}],
},
],
},
)
def test_document_link(self):
converter = ContentstateConverter(features=["document-link"])
result = json.loads(
converter.from_database_format(
"""
<p>a <a linktype="document" id="1">document</a> link</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "DOCUMENT",
"data": {
"id": 1,
"url": "/documents/1/test.pdf",
"filename": "test.pdf",
},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "a document link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 2, "length": 8, "key": 0}],
},
],
},
)
def test_broken_document_link(self):
converter = ContentstateConverter(features=["document-link"])
result = json.loads(
converter.from_database_format(
"""
<p>a <a linktype="document" id="9999">document</a> link</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "DOCUMENT",
"data": {"id": 9999},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "a document link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 2, "length": 8, "key": 0}],
},
],
},
)
def test_document_link_with_missing_id(self):
converter = ContentstateConverter(features=["document-link"])
result = json.loads(
converter.from_database_format(
"""
<p>a <a linktype="document">document</a> link</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {"mutability": "MUTABLE", "type": "DOCUMENT", "data": {}}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "a document link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 2, "length": 8, "key": 0}],
},
],
},
)
def test_image_embed(self):
converter = ContentstateConverter(features=["image"])
result = json.loads(
converter.from_database_format(
"""
<p>before</p>
<embed embedtype="image" alt="an image" id="1" format="left" />
<p>after</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "before",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "after",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
}
},
},
)
def test_add_spacer_paragraph_between_image_embeds(self):
converter = ContentstateConverter(features=["image"])
result = json.loads(
converter.from_database_format(
"""
<embed embedtype="image" alt="an image" id="1" format="left" />
<embed embedtype="image" alt="an image" id="1" format="left" />
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 1, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
},
"1": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
},
},
},
)
def test_image_after_list(self):
"""
There should be no spacer paragraph inserted between a list and an image
"""
converter = ContentstateConverter(features=["ul", "image"])
result = json.loads(
converter.from_database_format(
"""
<ul>
<li>Milk</li>
<li>Eggs</li>
</ul>
<embed embedtype="image" alt="an image" id="1" format="left" />
<ul>
<li>More milk</li>
<li>More eggs</li>
</ul>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {
"0": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
},
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Milk",
"depth": 0,
"type": "unordered-list-item",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Eggs",
"depth": 0,
"type": "unordered-list-item",
"key": "00000",
"entityRanges": [],
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 1, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"inlineStyleRanges": [],
"text": "More milk",
"depth": 0,
"type": "unordered-list-item",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "More eggs",
"depth": 0,
"type": "unordered-list-item",
"key": "00000",
"entityRanges": [],
},
],
},
)
@patch("wagtail.embeds.embeds.get_embed")
def test_media_embed(self, get_embed):
get_embed.return_value = Embed(
url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw",
max_width=None,
type="video",
html="test html",
title="what are birds",
author_name="look around you",
provider_name="YouTube",
thumbnail_url="http://test/thumbnail.url",
width=1000,
height=1000,
)
converter = ContentstateConverter(features=["embed"])
result = json.loads(
converter.from_database_format(
"""
<p>before</p>
<embed embedtype="media" url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw" />
<p>after</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "before",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "after",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {
"thumbnail": "http://test/thumbnail.url",
"embedType": "video",
"providerName": "YouTube",
"title": "what are birds",
"authorName": "look around you",
"url": "https://www.youtube.com/watch?v=Kh0Y2hVe_bw",
},
"mutability": "IMMUTABLE",
"type": "EMBED",
}
},
},
)
@patch("wagtail.embeds.embeds.get_embed")
def test_add_spacer_paras_between_media_embeds(self, get_embed):
get_embed.return_value = Embed(
url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw",
max_width=None,
type="video",
html="test html",
title="what are birds",
author_name="look around you",
provider_name="YouTube",
thumbnail_url="http://test/thumbnail.url",
width=1000,
height=1000,
)
converter = ContentstateConverter(features=["embed"])
result = json.loads(
converter.from_database_format(
"""
<embed embedtype="media" url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw" />
<embed embedtype="media" url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw" />
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 1, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {
"thumbnail": "http://test/thumbnail.url",
"embedType": "video",
"providerName": "YouTube",
"title": "what are birds",
"authorName": "look around you",
"url": "https://www.youtube.com/watch?v=Kh0Y2hVe_bw",
},
"mutability": "IMMUTABLE",
"type": "EMBED",
},
"1": {
"data": {
"thumbnail": "http://test/thumbnail.url",
"embedType": "video",
"providerName": "YouTube",
"title": "what are birds",
"authorName": "look around you",
"url": "https://www.youtube.com/watch?v=Kh0Y2hVe_bw",
},
"mutability": "IMMUTABLE",
"type": "EMBED",
},
},
},
)
def test_hr(self):
converter = ContentstateConverter(features=["hr"])
result = json.loads(
converter.from_database_format(
"""
<p>before</p>
<hr />
<p>after</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "before",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "after",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {},
"mutability": "IMMUTABLE",
"type": "HORIZONTAL_RULE",
}
},
},
)
def test_add_spacer_paragraph_between_hrs(self):
converter = ContentstateConverter(features=["hr"])
result = json.loads(
converter.from_database_format(
"""
<hr />
<hr />
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 1, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {},
"mutability": "IMMUTABLE",
"type": "HORIZONTAL_RULE",
},
"1": {
"data": {},
"mutability": "IMMUTABLE",
"type": "HORIZONTAL_RULE",
},
},
},
)
def test_block_element_in_paragraph(self):
converter = ContentstateConverter(features=["hr"])
result = json.loads(
converter.from_database_format(
"""
<p>before<hr />after</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "before",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "after",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {},
"mutability": "IMMUTABLE",
"type": "HORIZONTAL_RULE",
}
},
},
)
def test_br_element_in_paragraph(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
<p>before<br/>after</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "before\nafter",
"type": "unstyled",
}
],
},
)
def test_br_element_between_paragraphs(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
<p>before</p>
<br />
<p>after</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "before",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "after",
"type": "unstyled",
},
],
},
)
def test_block_element_in_empty_paragraph(self):
converter = ContentstateConverter(features=["hr"])
result = json.loads(
converter.from_database_format(
"""
<p><hr /></p>
"""
)
)
# ignoring the paragraph completely would probably be better,
# but we'll settle for an empty preceding paragraph and not crashing as the next best thing...
# (and if it's the first/last block we actually do want a spacer paragraph anyhow)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {},
"mutability": "IMMUTABLE",
"type": "HORIZONTAL_RULE",
}
},
},
)
def test_html_entities(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
<p>Arthur &quot;two sheds&quot; Jackson &lt;the third&gt; &amp; his wife</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": 'Arthur "two sheds" Jackson <the third> & his wife',
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_collapse_targeted_whitespace_characters(self):
# We expect all targeted whitespace characters (one or more consecutively)
# to be replaced by a single space. (\xa0 is a non-breaking whitespace)
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
<p>Multiple whitespaces: should be reduced</p>
<p>Multiple non-breaking whitespace characters: \xa0\xa0\xa0 should be preserved</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Multiple whitespaces: should be reduced",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Multiple non-breaking whitespace characters: \xa0\xa0\xa0 should be preserved",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_extra_end_tag_before(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
</p>
<p>Before</p>
"""
)
)
# The leading </p> tag should be ignored instead of blowing up with a
# pop from empty list error
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Before",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_extra_end_tag_after(self):
converter = ContentstateConverter(features=[])
result = json.loads(
converter.from_database_format(
"""
<p>After</p>
</p>
"""
)
)
# The tailing </p> tag should be ignored instead of blowing up with a
# pop from empty list error
self.assertContentStateEqual(
result,
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "After",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
},
)
def test_p_with_class(self):
# Test support for custom conversion rules which require correct treatment of
# CSS precedence in HTMLRuleset. Here, <p class="intro"> should match the
# 'p[class="intro"]' rule rather than 'p' and thus become an 'intro-paragraph' block
converter = ContentstateConverter(features=["intro"])
result = json.loads(
converter.from_database_format(
"""
<p class="intro">before</p>
<p>after</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "before",
"type": "intro-paragraph",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "after",
"type": "unstyled",
},
],
"entityMap": {},
},
)
def test_image_inside_paragraph(self):
# In Draftail's data model, images are block-level elements and therefore
# split up preceding / following text into their own paragraphs
converter = ContentstateConverter(features=["image"])
result = json.loads(
converter.from_database_format(
"""
<p>before <embed embedtype="image" alt="an image" id="1" format="left" /> after</p>
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "before",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [],
"depth": 0,
"text": "after",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
}
},
},
)
def test_image_inside_style(self):
# https://github.com/wagtail/wagtail/issues/4602 - ensure that an <embed> inside
# an inline style is handled. This is not valid in Draftail as images are block-level,
# but should be handled without errors, splitting the image into its own block
converter = ContentstateConverter(features=["image", "italic"])
result = json.loads(
converter.from_database_format(
"""
<p><i>before <embed embedtype="image" alt="an image" id="1" format="left" /> after</i></p>
<p><i><embed embedtype="image" alt="an image" id="1" format="left" /></i></p>
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [
{"offset": 0, "length": 6, "style": "ITALIC"}
],
"entityRanges": [],
"depth": 0,
"text": "before",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [
{"offset": 0, "length": 5, "style": "ITALIC"}
],
"entityRanges": [],
"depth": 0,
"text": "after",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [
{"offset": 0, "length": 0, "style": "ITALIC"}
],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 1, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [
{"offset": 0, "length": 0, "style": "ITALIC"}
],
"entityRanges": [],
"depth": 0,
"text": "",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
},
"1": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
},
},
},
)
def test_image_inside_link(self):
# https://github.com/wagtail/wagtail/issues/4602 - ensure that an <embed> inside
# a link is handled. This is not valid in Draftail as images are block-level,
# but should be handled without errors, splitting the image into its own block
converter = ContentstateConverter(features=["image", "link"])
result = json.loads(
converter.from_database_format(
"""
<p><a href="https://wagtail.org">before <embed embedtype="image" alt="an image" id="1" format="left" /> after</a></p>
<p><a href="https://wagtail.org"><embed embedtype="image" alt="an image" id="1" format="left" /></a></p>
"""
)
)
self.assertContentStateEqual(
result,
{
"blocks": [
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 6}],
"depth": 0,
"text": "before",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 1, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 0, "offset": 0, "length": 5}],
"depth": 0,
"text": "after",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 2, "offset": 0, "length": 0}],
"depth": 0,
"text": "",
"type": "unstyled",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 3, "offset": 0, "length": 1}],
"depth": 0,
"text": " ",
"type": "atomic",
},
{
"key": "00000",
"inlineStyleRanges": [],
"entityRanges": [{"key": 2, "offset": 0, "length": 0}],
"depth": 0,
"text": "",
"type": "unstyled",
},
],
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "https://wagtail.org"},
},
"1": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
},
"2": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "https://wagtail.org"},
},
"3": {
"data": {
"format": "left",
"alt": "an image",
"id": "1",
"src": "/media/not-found",
},
"mutability": "IMMUTABLE",
"type": "IMAGE",
},
},
},
)
class TestContentStateToHtml(TestCase):
def test_external_link(self):
converter = ContentstateConverter(features=["link"])
contentstate_json = json.dumps(
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "http://wagtail.org"},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an external link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 3, "length": 8, "key": 0}],
},
],
}
)
result = converter.to_database_format(contentstate_json)
self.assertEqual(
result,
'<p data-block-key="00000">an <a href="http://wagtail.org">external</a> link</p>',
)
def test_local_link(self):
converter = ContentstateConverter(features=["link"])
contentstate_json = json.dumps(
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "/some/local/path/"},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an external link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 3, "length": 8, "key": 0}],
},
],
}
)
result = converter.to_database_format(contentstate_json)
self.assertEqual(
result,
'<p data-block-key="00000">an <a href="/some/local/path/">external</a> link</p>',
)
def test_reject_javascript_link(self):
converter = ContentstateConverter(features=["link"])
contentstate_json = json.dumps(
{
"entityMap": {
"0": {
"mutability": "MUTABLE",
"type": "LINK",
"data": {"url": "javascript:alert('oh no')"},
}
},
"blocks": [
{
"inlineStyleRanges": [],
"text": "an external link",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [{"offset": 3, "length": 8, "key": 0}],
},
],
}
)
result = converter.to_database_format(contentstate_json)
self.assertEqual(
result, '<p data-block-key="00000">an <a>external</a> link</p>'
)
def test_paragraphs_retain_keys(self):
converter = ContentstateConverter(features=[])
contentState = json.dumps(
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Hello world!",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Goodbye world!",
"depth": 0,
"type": "unstyled",
"key": "00001",
"entityRanges": [],
},
],
}
)
result = converter.to_database_format(contentState)
self.assertHTMLEqual(
result,
"""
<p data-block-key='00000'>Hello world!</p>
<p data-block-key='00001'>Goodbye world!</p>
""",
)
def test_wrapped_block_retains_key(self):
# Test a block which uses a wrapper correctly receives the key defined on the inner element
converter = ContentstateConverter(features=["h1", "ol", "bold", "italic"])
result = converter.to_database_format(
json.dumps(
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "The rules of Fight Club",
"depth": 0,
"type": "header-one",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "You do not talk about Fight Club.",
"depth": 0,
"type": "ordered-list-item",
"key": "00001",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "You do not talk about Fight Club.",
"depth": 0,
"type": "ordered-list-item",
"key": "00002",
"entityRanges": [],
},
],
}
)
)
self.assertHTMLEqual(
result,
"""
<h1 data-block-key='00000'>The rules of Fight Club</h1>
<ol>
<li data-block-key='00001'>You do not talk about Fight Club.</li>
<li data-block-key='00002'>You do not talk about Fight Club.</li>
</ol>
""",
)
def test_wrap_block_function(self):
# Draft JS exporter's block_map config can also contain a function to handle a particular block
# Test that persist_key_for_block still works with such a function, making the resultant conversion
# keep the same block key between html and contentstate
exporter_config = {
"block_map": {
"unstyled": persist_key_for_block(
lambda props: DOM.create_element("p", {}, props["children"])
),
},
"style_map": {},
"entity_decorators": {},
"composite_decorators": [],
"engine": DOM.STRING,
}
contentState = {
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [],
"text": "Hello world!",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
{
"inlineStyleRanges": [],
"text": "Goodbye world!",
"depth": 0,
"type": "unstyled",
"key": "00001",
"entityRanges": [],
},
],
}
result = HTMLExporter(exporter_config).render(contentState)
self.assertHTMLEqual(
result,
"""
<p data-block-key='00000'>Hello world!</p>
<p data-block-key='00001'>Goodbye world!</p>
""",
)
def test_style_fallback(self):
# Test a block which uses an invalid inline style, and will be removed
converter = ContentstateConverter(features=[])
with self.assertLogs(level="WARNING") as log_output:
result = converter.to_database_format(
json.dumps(
{
"entityMap": {},
"blocks": [
{
"inlineStyleRanges": [
{"offset": 0, "length": 12, "style": "UNDERLINE"}
],
"text": "Hello world!",
"depth": 0,
"type": "unstyled",
"key": "00000",
"entityRanges": [],
},
],
}
)
)
self.assertHTMLEqual(
result,
"""
<p data-block-key="00000">
Hello world!
</p>
""",
)
self.assertIn(
'Missing config for "UNDERLINE". Deleting style.', log_output.output[0]
)