test_contentstate.py 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063
  1. import json
  2. from unittest.mock import patch
  3. from django.test import TestCase
  4. from draftjs_exporter.dom import DOM
  5. from draftjs_exporter.html import HTML as HTMLExporter
  6. from wagtail.admin.rich_text.converters.contentstate import (
  7. ContentstateConverter, persist_key_for_block)
  8. from wagtail.embeds.models import Embed
  9. def content_state_equal(v1, v2, match_keys=False):
  10. "Test whether two contentState structures are equal, ignoring 'key' properties if match_keys=False"
  11. if type(v1) != type(v2):
  12. return False
  13. if isinstance(v1, dict):
  14. if set(v1.keys()) != set(v2.keys()):
  15. return False
  16. return all(
  17. (k == 'key' and not match_keys) or content_state_equal(v, v2[k], match_keys=match_keys)
  18. for k, v in v1.items()
  19. )
  20. elif isinstance(v1, list):
  21. if len(v1) != len(v2):
  22. return False
  23. return all(
  24. content_state_equal(a, b, match_keys=match_keys) for a, b in zip(v1, v2)
  25. )
  26. else:
  27. return v1 == v2
  28. class TestHtmlToContentState(TestCase):
  29. fixtures = ['test.json']
  30. def assertContentStateEqual(self, v1, v2, match_keys=False):
  31. "Assert that two contentState structures are equal, ignoring 'key' properties if match_keys is False"
  32. self.assertTrue(
  33. content_state_equal(v1, v2, match_keys=match_keys),
  34. "%s does not match %s" % (json.dumps(v1, indent=4), json.dumps(v2, indent=4))
  35. )
  36. def test_paragraphs(self):
  37. converter = ContentstateConverter(features=[])
  38. result = json.loads(converter.from_database_format(
  39. '''
  40. <p data-block-key='00000'>Hello world!</p>
  41. <p data-block-key='00001'>Goodbye world!</p>
  42. '''
  43. ))
  44. self.assertContentStateEqual(result, {
  45. 'entityMap': {},
  46. 'blocks': [
  47. {'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  48. {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00001', 'entityRanges': []},
  49. ]
  50. }, match_keys=True)
  51. def test_unknown_block_becomes_paragraph(self):
  52. converter = ContentstateConverter(features=[])
  53. result = json.loads(converter.from_database_format(
  54. '''
  55. <foo>Hello world!</foo>
  56. <foo>I said hello world!</foo>
  57. <p>Goodbye world!</p>
  58. '''
  59. ))
  60. self.assertContentStateEqual(result, {
  61. 'entityMap': {},
  62. 'blocks': [
  63. {'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  64. {'inlineStyleRanges': [], 'text': 'I said hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  65. {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  66. ]
  67. })
  68. def test_bare_text_becomes_paragraph(self):
  69. converter = ContentstateConverter(features=[])
  70. result = json.loads(converter.from_database_format(
  71. '''
  72. before
  73. <p>paragraph</p>
  74. between
  75. <p>paragraph</p>
  76. after
  77. '''
  78. ))
  79. self.assertContentStateEqual(result, {
  80. 'entityMap': {},
  81. 'blocks': [
  82. {'inlineStyleRanges': [], 'text': 'before', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  83. {'inlineStyleRanges': [], 'text': 'paragraph', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  84. {'inlineStyleRanges': [], 'text': 'between', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  85. {'inlineStyleRanges': [], 'text': 'paragraph', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  86. {'inlineStyleRanges': [], 'text': 'after', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  87. ]
  88. })
  89. def test_ignore_unrecognised_tags_in_blocks(self):
  90. converter = ContentstateConverter(features=[])
  91. result = json.loads(converter.from_database_format(
  92. '''
  93. <p>Hello <foo>frabjuous</foo> world!</p>
  94. '''
  95. ))
  96. self.assertContentStateEqual(result, {
  97. 'entityMap': {},
  98. 'blocks': [
  99. {'inlineStyleRanges': [], 'text': 'Hello frabjuous world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  100. ]
  101. })
  102. def test_inline_styles(self):
  103. converter = ContentstateConverter(features=['bold', 'italic'])
  104. result = json.loads(converter.from_database_format(
  105. '''
  106. <p>You <b>do <em>not</em> talk</b> about Fight Club.</p>
  107. '''
  108. ))
  109. self.assertContentStateEqual(result, {
  110. 'entityMap': {},
  111. 'blocks': [
  112. {
  113. 'inlineStyleRanges': [
  114. {'offset': 4, 'length': 11, 'style': 'BOLD'}, {'offset': 7, 'length': 3, 'style': 'ITALIC'}
  115. ],
  116. 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []
  117. },
  118. ]
  119. })
  120. def test_inline_styles_at_top_level(self):
  121. converter = ContentstateConverter(features=['bold', 'italic'])
  122. result = json.loads(converter.from_database_format(
  123. '''
  124. You <b>do <em>not</em> talk</b> about Fight Club.
  125. '''
  126. ))
  127. self.assertContentStateEqual(result, {
  128. 'entityMap': {},
  129. 'blocks': [
  130. {
  131. 'inlineStyleRanges': [
  132. {'offset': 4, 'length': 11, 'style': 'BOLD'}, {'offset': 7, 'length': 3, 'style': 'ITALIC'}
  133. ],
  134. 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []
  135. },
  136. ]
  137. })
  138. def test_inline_styles_at_start_of_bare_block(self):
  139. converter = ContentstateConverter(features=['bold', 'italic'])
  140. result = json.loads(converter.from_database_format(
  141. '''<b>Seriously</b>, stop talking about <i>Fight Club</i> already.'''
  142. ))
  143. self.assertContentStateEqual(result, {
  144. 'entityMap': {},
  145. 'blocks': [
  146. {
  147. 'inlineStyleRanges': [
  148. {'offset': 0, 'length': 9, 'style': 'BOLD'},
  149. {'offset': 30, 'length': 10, 'style': 'ITALIC'},
  150. ],
  151. 'text': 'Seriously, stop talking about Fight Club already.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []
  152. },
  153. ]
  154. })
  155. def test_inline_styles_depend_on_features(self):
  156. converter = ContentstateConverter(features=['italic', 'just-made-it-up'])
  157. result = json.loads(converter.from_database_format(
  158. '''
  159. <p>You <b>do <em>not</em> talk</b> about Fight Club.</p>
  160. '''
  161. ))
  162. self.assertContentStateEqual(result, {
  163. 'entityMap': {},
  164. 'blocks': [
  165. {
  166. 'inlineStyleRanges': [
  167. {'offset': 7, 'length': 3, 'style': 'ITALIC'}
  168. ],
  169. 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []
  170. },
  171. ]
  172. })
  173. def test_ordered_list(self):
  174. converter = ContentstateConverter(features=['h1', 'ol', 'bold', 'italic'])
  175. result = json.loads(converter.from_database_format(
  176. '''
  177. <h1 data-block-key='00000'>The rules of Fight Club</h1>
  178. <ol>
  179. <li data-block-key='00001'>You do not talk about Fight Club.</li>
  180. <li data-block-key='00002'>You <b>do <em>not</em> talk</b> about Fight Club.</li>
  181. </ol>
  182. '''
  183. ))
  184. self.assertContentStateEqual(result, {
  185. 'entityMap': {},
  186. 'blocks': [
  187. {'inlineStyleRanges': [], 'text': 'The rules of Fight Club', 'depth': 0, 'type': 'header-one', 'key': '00000', 'entityRanges': []},
  188. {'inlineStyleRanges': [], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00001', 'entityRanges': []},
  189. {
  190. 'inlineStyleRanges': [
  191. {'offset': 4, 'length': 11, 'style': 'BOLD'}, {'offset': 7, 'length': 3, 'style': 'ITALIC'}
  192. ],
  193. 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00002', 'entityRanges': []
  194. },
  195. ]
  196. }, match_keys=True)
  197. def test_nested_list(self):
  198. converter = ContentstateConverter(features=['h1', 'ul'])
  199. result = json.loads(converter.from_database_format(
  200. '''
  201. <h1 data-block-key='00000'>Shopping list</h1>
  202. <ul>
  203. <li data-block-key='00001'>Milk</li>
  204. <li data-block-key='00002'>
  205. Flour
  206. <ul>
  207. <li data-block-key='00003'>Plain</li>
  208. <li data-block-key='00004'>Self-raising</li>
  209. </ul>
  210. </li>
  211. <li data-block-key='00005'>Eggs</li>
  212. </ul>
  213. '''
  214. ))
  215. self.assertContentStateEqual(result, {
  216. 'entityMap': {},
  217. 'blocks': [
  218. {'inlineStyleRanges': [], 'text': 'Shopping list', 'depth': 0, 'type': 'header-one', 'key': '00000', 'entityRanges': []},
  219. {'inlineStyleRanges': [], 'text': 'Milk', 'depth': 0, 'type': 'unordered-list-item', 'key': '00001', 'entityRanges': []},
  220. {'inlineStyleRanges': [], 'text': 'Flour', 'depth': 0, 'type': 'unordered-list-item', 'key': '00002', 'entityRanges': []},
  221. {'inlineStyleRanges': [], 'text': 'Plain', 'depth': 1, 'type': 'unordered-list-item', 'key': '00003', 'entityRanges': []},
  222. {'inlineStyleRanges': [], 'text': 'Self-raising', 'depth': 1, 'type': 'unordered-list-item', 'key': '00004', 'entityRanges': []},
  223. {'inlineStyleRanges': [], 'text': 'Eggs', 'depth': 0, 'type': 'unordered-list-item', 'key': '00005', 'entityRanges': []},
  224. ]
  225. }, match_keys=True)
  226. def test_external_link(self):
  227. converter = ContentstateConverter(features=['link'])
  228. result = json.loads(converter.from_database_format(
  229. '''
  230. <p>an <a href="http://wagtail.org">external</a> link</p>
  231. '''
  232. ))
  233. self.assertContentStateEqual(result, {
  234. 'entityMap': {
  235. '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'http://wagtail.org'}}
  236. },
  237. 'blocks': [
  238. {
  239. 'inlineStyleRanges': [], 'text': 'an external link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  240. 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}]
  241. },
  242. ]
  243. })
  244. def test_link_in_bare_text(self):
  245. converter = ContentstateConverter(features=['link'])
  246. result = json.loads(converter.from_database_format(
  247. '''an <a href="http://wagtail.org">external</a> link'''
  248. ))
  249. self.assertContentStateEqual(result, {
  250. 'entityMap': {
  251. '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'http://wagtail.org'}}
  252. },
  253. 'blocks': [
  254. {
  255. 'inlineStyleRanges': [], 'text': 'an external link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  256. 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}]
  257. },
  258. ]
  259. })
  260. def test_link_at_start_of_bare_text(self):
  261. converter = ContentstateConverter(features=['link'])
  262. result = json.loads(converter.from_database_format(
  263. '''<a href="http://wagtail.org">an external link</a> and <a href="http://torchbox.com">another</a>'''
  264. ))
  265. self.assertContentStateEqual(result, {
  266. 'entityMap': {
  267. '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'http://wagtail.org'}},
  268. '1': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'http://torchbox.com'}},
  269. },
  270. 'blocks': [
  271. {
  272. 'inlineStyleRanges': [], 'text': 'an external link and another', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  273. 'entityRanges': [
  274. {'offset': 0, 'length': 16, 'key': 0},
  275. {'offset': 21, 'length': 7, 'key': 1},
  276. ]
  277. },
  278. ]
  279. })
  280. def test_page_link(self):
  281. converter = ContentstateConverter(features=['link'])
  282. result = json.loads(converter.from_database_format(
  283. '''
  284. <p>an <a linktype="page" id="3">internal</a> link</p>
  285. '''
  286. ))
  287. self.assertContentStateEqual(result, {
  288. 'entityMap': {
  289. '0': {
  290. 'mutability': 'MUTABLE', 'type': 'LINK',
  291. 'data': {'id': 3, 'url': '/events/', 'parentId': 2}
  292. }
  293. },
  294. 'blocks': [
  295. {
  296. 'inlineStyleRanges': [], 'text': 'an internal link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  297. 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}]
  298. },
  299. ]
  300. })
  301. def test_broken_page_link(self):
  302. converter = ContentstateConverter(features=['link'])
  303. result = json.loads(converter.from_database_format(
  304. '''
  305. <p>an <a linktype="page" id="9999">internal</a> link</p>
  306. '''
  307. ))
  308. self.assertContentStateEqual(result, {
  309. 'entityMap': {
  310. '0': {
  311. 'mutability': 'MUTABLE', 'type': 'LINK',
  312. 'data': {
  313. 'id': 9999, 'url': None, 'parentId': None,
  314. }
  315. }
  316. },
  317. 'blocks': [
  318. {
  319. 'inlineStyleRanges': [], 'text': 'an internal link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  320. 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}]
  321. },
  322. ]
  323. })
  324. def test_link_to_root_page(self):
  325. converter = ContentstateConverter(features=['link'])
  326. result = json.loads(converter.from_database_format(
  327. '''
  328. <p>an <a linktype="page" id="1">internal</a> link</p>
  329. '''
  330. ))
  331. self.assertContentStateEqual(result, {
  332. 'entityMap': {
  333. '0': {
  334. 'mutability': 'MUTABLE', 'type': 'LINK',
  335. 'data': {'id': 1, 'url': None, 'parentId': None}
  336. }
  337. },
  338. 'blocks': [
  339. {
  340. 'inlineStyleRanges': [], 'text': 'an internal link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  341. 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}]
  342. },
  343. ]
  344. })
  345. def test_document_link(self):
  346. converter = ContentstateConverter(features=['document-link'])
  347. result = json.loads(converter.from_database_format(
  348. '''
  349. <p>a <a linktype="document" id="1">document</a> link</p>
  350. '''
  351. ))
  352. self.assertContentStateEqual(result, {
  353. 'entityMap': {
  354. '0': {
  355. 'mutability': 'MUTABLE', 'type': 'DOCUMENT',
  356. 'data': {'id': 1, 'url': '/documents/1/test.pdf', 'filename': 'test.pdf'}
  357. }
  358. },
  359. 'blocks': [
  360. {
  361. 'inlineStyleRanges': [], 'text': 'a document link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  362. 'entityRanges': [{'offset': 2, 'length': 8, 'key': 0}]
  363. },
  364. ]
  365. })
  366. def test_broken_document_link(self):
  367. converter = ContentstateConverter(features=['document-link'])
  368. result = json.loads(converter.from_database_format(
  369. '''
  370. <p>a <a linktype="document" id="9999">document</a> link</p>
  371. '''
  372. ))
  373. self.assertContentStateEqual(result, {
  374. 'entityMap': {
  375. '0': {
  376. 'mutability': 'MUTABLE', 'type': 'DOCUMENT',
  377. 'data': {'id': 9999}
  378. }
  379. },
  380. 'blocks': [
  381. {
  382. 'inlineStyleRanges': [], 'text': 'a document link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  383. 'entityRanges': [{'offset': 2, 'length': 8, 'key': 0}]
  384. },
  385. ]
  386. })
  387. def test_document_link_with_missing_id(self):
  388. converter = ContentstateConverter(features=['document-link'])
  389. result = json.loads(converter.from_database_format(
  390. '''
  391. <p>a <a linktype="document">document</a> link</p>
  392. '''
  393. ))
  394. self.assertContentStateEqual(result, {
  395. 'entityMap': {
  396. '0': {
  397. 'mutability': 'MUTABLE', 'type': 'DOCUMENT',
  398. 'data': {}
  399. }
  400. },
  401. 'blocks': [
  402. {
  403. 'inlineStyleRanges': [], 'text': 'a document link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  404. 'entityRanges': [{'offset': 2, 'length': 8, 'key': 0}]
  405. },
  406. ]
  407. })
  408. def test_image_embed(self):
  409. converter = ContentstateConverter(features=['image'])
  410. result = json.loads(converter.from_database_format(
  411. '''
  412. <p>before</p>
  413. <embed embedtype="image" alt="an image" id="1" format="left" />
  414. <p>after</p>
  415. '''
  416. ))
  417. self.assertContentStateEqual(result, {
  418. 'blocks': [
  419. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled'},
  420. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  421. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'}
  422. ],
  423. 'entityMap': {
  424. '0': {
  425. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  426. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  427. }
  428. }
  429. })
  430. def test_add_spacer_paragraph_between_image_embeds(self):
  431. converter = ContentstateConverter(features=['image'])
  432. result = json.loads(converter.from_database_format(
  433. '''
  434. <embed embedtype="image" alt="an image" id="1" format="left" />
  435. <embed embedtype="image" alt="an image" id="1" format="left" />
  436. '''
  437. ))
  438. self.assertContentStateEqual(result, {
  439. 'blocks': [
  440. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  441. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  442. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  443. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  444. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  445. ],
  446. 'entityMap': {
  447. '0': {
  448. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  449. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  450. },
  451. '1': {
  452. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  453. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  454. },
  455. }
  456. })
  457. def test_image_after_list(self):
  458. """
  459. There should be no spacer paragraph inserted between a list and an image
  460. """
  461. converter = ContentstateConverter(features=['ul', 'image'])
  462. result = json.loads(converter.from_database_format(
  463. '''
  464. <ul>
  465. <li>Milk</li>
  466. <li>Eggs</li>
  467. </ul>
  468. <embed embedtype="image" alt="an image" id="1" format="left" />
  469. <ul>
  470. <li>More milk</li>
  471. <li>More eggs</li>
  472. </ul>
  473. '''
  474. ))
  475. self.assertContentStateEqual(result, {
  476. 'entityMap': {
  477. '0': {
  478. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  479. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  480. },
  481. },
  482. 'blocks': [
  483. {'inlineStyleRanges': [], 'text': 'Milk', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
  484. {'inlineStyleRanges': [], 'text': 'Eggs', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
  485. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  486. {'inlineStyleRanges': [], 'text': 'More milk', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
  487. {'inlineStyleRanges': [], 'text': 'More eggs', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
  488. ]
  489. })
  490. @patch('wagtail.embeds.embeds.get_embed')
  491. def test_media_embed(self, get_embed):
  492. get_embed.return_value = Embed(
  493. url='https://www.youtube.com/watch?v=Kh0Y2hVe_bw',
  494. max_width=None,
  495. type='video',
  496. html='test html',
  497. title='what are birds',
  498. author_name='look around you',
  499. provider_name='YouTube',
  500. thumbnail_url='http://test/thumbnail.url',
  501. width=1000,
  502. height=1000,
  503. )
  504. converter = ContentstateConverter(features=['embed'])
  505. result = json.loads(converter.from_database_format(
  506. '''
  507. <p>before</p>
  508. <embed embedtype="media" url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw" />
  509. <p>after</p>
  510. '''
  511. ))
  512. self.assertContentStateEqual(result, {
  513. 'blocks': [
  514. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled'},
  515. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  516. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'}
  517. ],
  518. 'entityMap': {
  519. '0': {
  520. 'data': {
  521. 'thumbnail': 'http://test/thumbnail.url',
  522. 'embedType': 'video',
  523. 'providerName': 'YouTube',
  524. 'title': 'what are birds',
  525. 'authorName': 'look around you',
  526. 'url': 'https://www.youtube.com/watch?v=Kh0Y2hVe_bw'
  527. },
  528. 'mutability': 'IMMUTABLE', 'type': 'EMBED'
  529. }
  530. }
  531. })
  532. @patch('wagtail.embeds.embeds.get_embed')
  533. def test_add_spacer_paras_between_media_embeds(self, get_embed):
  534. get_embed.return_value = Embed(
  535. url='https://www.youtube.com/watch?v=Kh0Y2hVe_bw',
  536. max_width=None,
  537. type='video',
  538. html='test html',
  539. title='what are birds',
  540. author_name='look around you',
  541. provider_name='YouTube',
  542. thumbnail_url='http://test/thumbnail.url',
  543. width=1000,
  544. height=1000,
  545. )
  546. converter = ContentstateConverter(features=['embed'])
  547. result = json.loads(converter.from_database_format(
  548. '''
  549. <embed embedtype="media" url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw" />
  550. <embed embedtype="media" url="https://www.youtube.com/watch?v=Kh0Y2hVe_bw" />
  551. '''
  552. ))
  553. self.assertContentStateEqual(result, {
  554. 'blocks': [
  555. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  556. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  557. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  558. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  559. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  560. ],
  561. 'entityMap': {
  562. '0': {
  563. 'data': {
  564. 'thumbnail': 'http://test/thumbnail.url',
  565. 'embedType': 'video',
  566. 'providerName': 'YouTube',
  567. 'title': 'what are birds',
  568. 'authorName': 'look around you',
  569. 'url': 'https://www.youtube.com/watch?v=Kh0Y2hVe_bw'
  570. },
  571. 'mutability': 'IMMUTABLE', 'type': 'EMBED'
  572. },
  573. '1': {
  574. 'data': {
  575. 'thumbnail': 'http://test/thumbnail.url',
  576. 'embedType': 'video',
  577. 'providerName': 'YouTube',
  578. 'title': 'what are birds',
  579. 'authorName': 'look around you',
  580. 'url': 'https://www.youtube.com/watch?v=Kh0Y2hVe_bw'
  581. },
  582. 'mutability': 'IMMUTABLE', 'type': 'EMBED'
  583. },
  584. }
  585. })
  586. def test_hr(self):
  587. converter = ContentstateConverter(features=['hr'])
  588. result = json.loads(converter.from_database_format(
  589. '''
  590. <p>before</p>
  591. <hr />
  592. <p>after</p>
  593. '''
  594. ))
  595. self.assertContentStateEqual(result, {
  596. 'blocks': [
  597. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled'},
  598. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  599. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'}
  600. ],
  601. 'entityMap': {
  602. '0': {
  603. 'data': {},
  604. 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE'
  605. }
  606. }
  607. })
  608. def test_add_spacer_paragraph_between_hrs(self):
  609. converter = ContentstateConverter(features=['hr'])
  610. result = json.loads(converter.from_database_format(
  611. '''
  612. <hr />
  613. <hr />
  614. '''
  615. ))
  616. self.assertContentStateEqual(result, {
  617. 'blocks': [
  618. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  619. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  620. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  621. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  622. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  623. ],
  624. 'entityMap': {
  625. '0': {
  626. 'data': {},
  627. 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE'
  628. },
  629. '1': {
  630. 'data': {},
  631. 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE'
  632. },
  633. }
  634. })
  635. def test_block_element_in_paragraph(self):
  636. converter = ContentstateConverter(features=['hr'])
  637. result = json.loads(converter.from_database_format(
  638. '''
  639. <p>before<hr />after</p>
  640. '''
  641. ))
  642. self.assertContentStateEqual(result, {
  643. 'blocks': [
  644. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled'},
  645. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  646. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'}
  647. ],
  648. 'entityMap': {
  649. '0': {
  650. 'data': {},
  651. 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE'
  652. }
  653. }
  654. })
  655. def test_br_element_in_paragraph(self):
  656. converter = ContentstateConverter(features=[])
  657. result = json.loads(converter.from_database_format(
  658. '''
  659. <p>before<br/>after</p>
  660. '''
  661. ))
  662. self.assertContentStateEqual(result, {
  663. 'entityMap': {},
  664. 'blocks': [
  665. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before\nafter',
  666. 'type': 'unstyled'}
  667. ],
  668. })
  669. def test_br_element_between_paragraphs(self):
  670. converter = ContentstateConverter(features=[])
  671. result = json.loads(converter.from_database_format(
  672. '''
  673. <p>before</p>
  674. <br />
  675. <p>after</p>
  676. '''
  677. ))
  678. self.assertContentStateEqual(result, {
  679. 'entityMap': {},
  680. 'blocks': [
  681. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled'},
  682. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'}
  683. ],
  684. })
  685. def test_block_element_in_empty_paragraph(self):
  686. converter = ContentstateConverter(features=['hr'])
  687. result = json.loads(converter.from_database_format(
  688. '''
  689. <p><hr /></p>
  690. '''
  691. ))
  692. # ignoring the paragraph completely would probably be better,
  693. # but we'll settle for an empty preceding paragraph and not crashing as the next best thing...
  694. # (and if it's the first/last block we actually do want a spacer paragraph anyhow)
  695. self.assertContentStateEqual(result, {
  696. 'blocks': [
  697. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  698. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  699. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  700. ],
  701. 'entityMap': {
  702. '0': {
  703. 'data': {},
  704. 'mutability': 'IMMUTABLE', 'type': 'HORIZONTAL_RULE'
  705. }
  706. }
  707. })
  708. def test_html_entities(self):
  709. converter = ContentstateConverter(features=[])
  710. result = json.loads(converter.from_database_format(
  711. '''
  712. <p>Arthur &quot;two sheds&quot; Jackson &lt;the third&gt; &amp; his wife</p>
  713. '''
  714. ))
  715. self.assertContentStateEqual(result, {
  716. 'entityMap': {},
  717. 'blocks': [
  718. {'inlineStyleRanges': [], 'text': 'Arthur "two sheds" Jackson <the third> & his wife', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  719. ]
  720. })
  721. def test_collapse_targeted_whitespace_characters(self):
  722. # We expect all targeted whitespace characters (one or more consecutively)
  723. # to be replaced by a single space. (\xa0 is a non-breaking whitespace)
  724. converter = ContentstateConverter(features=[])
  725. result = json.loads(converter.from_database_format(
  726. '''
  727. <p>Multiple whitespaces: should be reduced</p>
  728. <p>Multiple non-breaking whitespace characters: \xa0\xa0\xa0 should be preserved</p>
  729. '''
  730. ))
  731. self.assertContentStateEqual(result, {
  732. 'entityMap': {},
  733. 'blocks': [
  734. {'inlineStyleRanges': [], 'text': 'Multiple whitespaces: should be reduced', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  735. {'inlineStyleRanges': [], 'text': 'Multiple non-breaking whitespace characters: \xa0\xa0\xa0 should be preserved', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  736. ]
  737. })
  738. def test_extra_end_tag_before(self):
  739. converter = ContentstateConverter(features=[])
  740. result = json.loads(converter.from_database_format(
  741. '''
  742. </p>
  743. <p>Before</p>
  744. '''
  745. ))
  746. # The leading </p> tag should be ignored instead of blowing up with a
  747. # pop from empty list error
  748. self.assertContentStateEqual(result, {
  749. 'entityMap': {},
  750. 'blocks': [
  751. {'inlineStyleRanges': [], 'text': 'Before', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  752. ]
  753. })
  754. def test_extra_end_tag_after(self):
  755. converter = ContentstateConverter(features=[])
  756. result = json.loads(converter.from_database_format(
  757. '''
  758. <p>After</p>
  759. </p>
  760. '''
  761. ))
  762. # The tailing </p> tag should be ignored instead of blowing up with a
  763. # pop from empty list error
  764. self.assertContentStateEqual(result, {
  765. 'entityMap': {},
  766. 'blocks': [
  767. {'inlineStyleRanges': [], 'text': 'After', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  768. ]
  769. })
  770. def test_p_with_class(self):
  771. # Test support for custom conversion rules which require correct treatment of
  772. # CSS precedence in HTMLRuleset. Here, <p class="intro"> should match the
  773. # 'p[class="intro"]' rule rather than 'p' and thus become an 'intro-paragraph' block
  774. converter = ContentstateConverter(features=['intro'])
  775. result = json.loads(converter.from_database_format(
  776. '''
  777. <p class="intro">before</p>
  778. <p>after</p>
  779. '''
  780. ))
  781. self.assertContentStateEqual(result, {
  782. 'blocks': [
  783. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'intro-paragraph'},
  784. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'}
  785. ],
  786. 'entityMap': {}
  787. })
  788. def test_image_inside_paragraph(self):
  789. # In Draftail's data model, images are block-level elements and therefore
  790. # split up preceding / following text into their own paragraphs
  791. converter = ContentstateConverter(features=['image'])
  792. result = json.loads(converter.from_database_format(
  793. '''
  794. <p>before <embed embedtype="image" alt="an image" id="1" format="left" /> after</p>
  795. '''
  796. ))
  797. self.assertContentStateEqual(result, {
  798. 'blocks': [
  799. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled'},
  800. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  801. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'}
  802. ],
  803. 'entityMap': {
  804. '0': {
  805. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  806. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  807. }
  808. }
  809. })
  810. def test_image_inside_style(self):
  811. # https://github.com/wagtail/wagtail/issues/4602 - ensure that an <embed> inside
  812. # an inline style is handled. This is not valid in Draftail as images are block-level,
  813. # but should be handled without errors, splitting the image into its own block
  814. converter = ContentstateConverter(features=['image', 'italic'])
  815. result = json.loads(converter.from_database_format(
  816. '''
  817. <p><i>before <embed embedtype="image" alt="an image" id="1" format="left" /> after</i></p>
  818. <p><i><embed embedtype="image" alt="an image" id="1" format="left" /></i></p>
  819. '''
  820. ))
  821. self.assertContentStateEqual(result, {
  822. 'blocks': [
  823. {'key': '00000', 'inlineStyleRanges': [{'offset': 0, 'length': 6, 'style': 'ITALIC'}], 'entityRanges': [], 'depth': 0, 'text': 'before', 'type': 'unstyled'},
  824. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  825. {'key': '00000', 'inlineStyleRanges': [{'offset': 0, 'length': 5, 'style': 'ITALIC'}], 'entityRanges': [], 'depth': 0, 'text': 'after', 'type': 'unstyled'},
  826. {'key': '00000', 'inlineStyleRanges': [{'offset': 0, 'length': 0, 'style': 'ITALIC'}], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  827. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  828. {'key': '00000', 'inlineStyleRanges': [{'offset': 0, 'length': 0, 'style': 'ITALIC'}], 'entityRanges': [], 'depth': 0, 'text': '', 'type': 'unstyled'},
  829. ],
  830. 'entityMap': {
  831. '0': {
  832. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  833. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  834. },
  835. '1': {
  836. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  837. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  838. },
  839. }
  840. })
  841. def test_image_inside_link(self):
  842. # https://github.com/wagtail/wagtail/issues/4602 - ensure that an <embed> inside
  843. # a link is handled. This is not valid in Draftail as images are block-level,
  844. # but should be handled without errors, splitting the image into its own block
  845. converter = ContentstateConverter(features=['image', 'link'])
  846. result = json.loads(converter.from_database_format(
  847. '''
  848. <p><a href="https://wagtail.org">before <embed embedtype="image" alt="an image" id="1" format="left" /> after</a></p>
  849. <p><a href="https://wagtail.org"><embed embedtype="image" alt="an image" id="1" format="left" /></a></p>
  850. '''
  851. ))
  852. self.assertContentStateEqual(result, {
  853. 'blocks': [
  854. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 6}], 'depth': 0, 'text': 'before', 'type': 'unstyled'},
  855. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 1, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  856. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 0, 'offset': 0, 'length': 5}], 'depth': 0, 'text': 'after', 'type': 'unstyled'},
  857. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 2, 'offset': 0, 'length': 0}], 'depth': 0, 'text': '', 'type': 'unstyled'},
  858. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 3, 'offset': 0, 'length': 1}], 'depth': 0, 'text': ' ', 'type': 'atomic'},
  859. {'key': '00000', 'inlineStyleRanges': [], 'entityRanges': [{'key': 2, 'offset': 0, 'length': 0}], 'depth': 0, 'text': '', 'type': 'unstyled'},
  860. ],
  861. 'entityMap': {
  862. '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'https://wagtail.org'}},
  863. '1': {
  864. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  865. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  866. },
  867. '2': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'https://wagtail.org'}},
  868. '3': {
  869. 'data': {'format': 'left', 'alt': 'an image', 'id': '1', 'src': '/media/not-found'},
  870. 'mutability': 'IMMUTABLE', 'type': 'IMAGE'
  871. },
  872. }
  873. })
  874. class TestContentStateToHtml(TestCase):
  875. def test_external_link(self):
  876. converter = ContentstateConverter(features=['link'])
  877. contentstate_json = json.dumps({
  878. 'entityMap': {
  879. '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': 'http://wagtail.org'}}
  880. },
  881. 'blocks': [
  882. {
  883. 'inlineStyleRanges': [], 'text': 'an external link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  884. 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}]
  885. },
  886. ]
  887. })
  888. result = converter.to_database_format(contentstate_json)
  889. self.assertEqual(result, '<p data-block-key="00000">an <a href="http://wagtail.org">external</a> link</p>')
  890. def test_local_link(self):
  891. converter = ContentstateConverter(features=['link'])
  892. contentstate_json = json.dumps({
  893. 'entityMap': {
  894. '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': '/some/local/path/'}}
  895. },
  896. 'blocks': [
  897. {
  898. 'inlineStyleRanges': [], 'text': 'an external link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  899. 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}]
  900. },
  901. ]
  902. })
  903. result = converter.to_database_format(contentstate_json)
  904. self.assertEqual(result, '<p data-block-key="00000">an <a href="/some/local/path/">external</a> link</p>')
  905. def test_reject_javascript_link(self):
  906. converter = ContentstateConverter(features=['link'])
  907. contentstate_json = json.dumps({
  908. 'entityMap': {
  909. '0': {'mutability': 'MUTABLE', 'type': 'LINK', 'data': {'url': "javascript:alert('oh no')"}}
  910. },
  911. 'blocks': [
  912. {
  913. 'inlineStyleRanges': [], 'text': 'an external link', 'depth': 0, 'type': 'unstyled', 'key': '00000',
  914. 'entityRanges': [{'offset': 3, 'length': 8, 'key': 0}]
  915. },
  916. ]
  917. })
  918. result = converter.to_database_format(contentstate_json)
  919. self.assertEqual(result, '<p data-block-key="00000">an <a>external</a> link</p>')
  920. def test_paragraphs_retain_keys(self):
  921. converter = ContentstateConverter(features=[])
  922. contentState = json.dumps({
  923. 'entityMap': {},
  924. 'blocks': [
  925. {'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  926. {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00001', 'entityRanges': []},
  927. ]
  928. })
  929. result = converter.to_database_format(contentState)
  930. self.assertHTMLEqual(result, '''
  931. <p data-block-key='00000'>Hello world!</p>
  932. <p data-block-key='00001'>Goodbye world!</p>
  933. ''')
  934. def test_wrapped_block_retains_key(self):
  935. # Test a block which uses a wrapper correctly receives the key defined on the inner element
  936. converter = ContentstateConverter(features=['h1', 'ol', 'bold', 'italic'])
  937. result = converter.to_database_format(json.dumps({
  938. 'entityMap': {},
  939. 'blocks': [
  940. {'inlineStyleRanges': [], 'text': 'The rules of Fight Club', 'depth': 0, 'type': 'header-one', 'key': '00000', 'entityRanges': []},
  941. {'inlineStyleRanges': [], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00001', 'entityRanges': []},
  942. {
  943. 'inlineStyleRanges': [],
  944. 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00002', 'entityRanges': []
  945. },
  946. ]
  947. }))
  948. self.assertHTMLEqual(result, '''
  949. <h1 data-block-key='00000'>The rules of Fight Club</h1>
  950. <ol>
  951. <li data-block-key='00001'>You do not talk about Fight Club.</li>
  952. <li data-block-key='00002'>You do not talk about Fight Club.</li>
  953. </ol>
  954. ''')
  955. def test_wrap_block_function(self):
  956. # Draft JS exporter's block_map config can also contain a function to handle a particular block
  957. # Test that persist_key_for_block still works with such a function, making the resultant conversion
  958. # keep the same block key between html and contentstate
  959. exporter_config = {
  960. 'block_map': {
  961. 'unstyled': persist_key_for_block(lambda props: DOM.create_element('p', {}, props['children'])),
  962. },
  963. 'style_map': {},
  964. 'entity_decorators': {},
  965. 'composite_decorators': [],
  966. 'engine': DOM.STRING,
  967. }
  968. contentState = {
  969. 'entityMap': {},
  970. 'blocks': [
  971. {'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
  972. {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00001', 'entityRanges': []},
  973. ]
  974. }
  975. result = HTMLExporter(exporter_config).render(contentState)
  976. self.assertHTMLEqual(result, '''
  977. <p data-block-key='00000'>Hello world!</p>
  978. <p data-block-key='00001'>Goodbye world!</p>
  979. ''')
  980. def test_style_fallback(self):
  981. # Test a block which uses an invalid inline style, and will be removed
  982. converter = ContentstateConverter(features=[])
  983. with self.assertLogs(level='WARNING') as log_output:
  984. result = converter.to_database_format(json.dumps({
  985. 'entityMap': {},
  986. 'blocks': [
  987. {
  988. 'inlineStyleRanges': [{'offset': 0, 'length': 12, 'style': 'UNDERLINE'}],
  989. 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []
  990. },
  991. ]
  992. }))
  993. self.assertHTMLEqual(result, '''
  994. <p data-block-key="00000">
  995. Hello world!
  996. </p>
  997. ''')
  998. self.assertIn(
  999. 'Missing config for "UNDERLINE". Deleting style.',
  1000. log_output.output[0]
  1001. )