2086 lines
71 KiB
Python
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 "two sheds" Jackson <the third> & 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]
|
|
)
|