edwh-editorjs 2.5.0a4__tar.gz → 2.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,24 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v2.6.1 (2026-02-05)
6
+
7
+ ### Fix
8
+
9
+ * **paragraph:** Handle nested HTML in alignment tag closing detection ([`7f910e1`](https://github.com/educationwarehouse/edwh-editorjs/commit/7f910e15103fe25ae0e4d1158deecc15d267d926))
10
+
11
+ ## v2.6.0 (2026-02-05)
12
+
13
+ ### Fix
14
+
15
+ * **custom:** Replace recursive html parser with lxml to only parse outer layer and keep inner HTML intact ([`8a8908c`](https://github.com/educationwarehouse/edwh-editorjs/commit/8a8908c30708573df11f2011b24fda8c4984de21))
16
+
17
+ ## v2.5.0 (2025-03-20)
18
+
19
+ ### Feature
20
+
21
+ * Improve markdown conversion and image handling in EditorJS blocks ([`80d0fc0`](https://github.com/educationwarehouse/edwh-editorjs/commit/80d0fc00a4e39b279de35998d1a85a37282c58a7))
22
+
5
23
  ## v2.4.0 (2024-12-02)
6
24
 
7
25
  ### Feature
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: edwh-editorjs
3
- Version: 2.5.0a4
3
+ Version: 2.6.1
4
4
  Summary: EditorJS.py
5
5
  Project-URL: Homepage, https://github.com/educationwarehouse/edwh-EditorJS
6
6
  Author-email: SKevo <skevo.cw@gmail.com>, Robin van der Noord <robin.vdn@educationwarehouse.nl>
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Requires-Python: >=3.10
16
16
  Requires-Dist: html2markdown
17
17
  Requires-Dist: humanize
18
+ Requires-Dist: lxml
18
19
  Requires-Dist: markdown2
19
20
  Requires-Dist: mdast
20
21
  Provides-Extra: dev
@@ -0,0 +1,42 @@
1
+ diff --git a/editorjs/blocks.py b/editorjs/blocks.py
2
+ index 9627a82..bc93cee 100644
3
+ --- a/editorjs/blocks.py
4
+ +++ b/editorjs/blocks.py
5
+ @@ -210,14 +210,30 @@ class ParagraphBlock(EditorJSBlock):
6
+
7
+ if child.get("value", "").endswith("/>"):
8
+ # self-closing
9
+ - result.append(EditorJSCustom.to_json(node))
10
+ + result.append(EditorJSCustom.to_json({"children": [child]}))
11
+ else:
12
+ - # <editorjs>something</editorjs> = 3 children
13
+ - result.extend(
14
+ - EditorJSCustom.to_json({"children": nodes[idx : idx + 2]})
15
+ - )
16
+ -
17
+ - skip = 2
18
+ + # <editorjs>...</editorjs> may include nested HTML; find the closing tag
19
+ + end_idx = None
20
+ + for j, next_child in enumerate(nodes[idx + 1 :], start=idx + 1):
21
+ + if next_child.get("type") == "html" and next_child.get(
22
+ + "value", ""
23
+ + ).strip() == "</editorjs>":
24
+ + end_idx = j
25
+ + break
26
+ +
27
+ + if end_idx is None:
28
+ + # fallback to previous behavior if tag is malformed
29
+ + result.extend(
30
+ + EditorJSCustom.to_json({"children": nodes[idx : idx + 2]})
31
+ + )
32
+ + skip = 2
33
+ + else:
34
+ + result.extend(
35
+ + EditorJSCustom.to_json(
36
+ + {"children": nodes[idx : end_idx + 1]}
37
+ + )
38
+ + )
39
+ + skip = end_idx - idx
40
+
41
+ continue
42
+
@@ -0,0 +1,86 @@
1
+ Via de pilot **‘Ambtenaar in de Praktijk’** doen Leidse ambtenaren gerichte praktijkervaring op in de stad. Zo loopt Lies Timmering, projectleider van de **Leidse Gelijke Kansen Aanpak**, elke woensdagmorgen mee bij **BuZz**, waar zij te vinden is bij de buurtsoep en meewerkt met bewoners en vrijwilligers. In deze blog deelt Lies haar persoonlijke reflectie op wat deze ervaringen voor haar betekenen en hoe ze helpen om beleid beter te laten aansluiten op wat er écht speelt in de stad.
2
+
3
+ <editorjs type="image" caption="Foto: BuZz Leiden&nbsp;" border="" background="" stretched="1" url="https://py4web.onderwijsindeleidseregio.nl/thumb/upload/af1ac9a1-fd7a-416e-a5dc-6ee97266397d.jpg?hash=075d9622bd25e284eb23ea34a22537dd707d8437" />
4
+
5
+ ***
6
+
7
+ Vandaag pakte ik mijn eed en belofte er nog eens bij. Ik beloofde als ambtenaar bij de start van mijn functie ‘plechtig’ het volgende:  
8
+
9
+ 1. Ik zal de gerechtigheid dienen. 
10
+ 2. Ik zal trouw zijn aan de grondwet en de overige wetten van het rijk.
11
+ 3. Ik zal mij inzetten voor het welzijn en de rechten van alle burgers van Leiden.
12
+ 4. Ik zal onpartijdig handelen en de democratische beginselen en procedures respecteren.
13
+ 5. Ik ben loyaal ten opzichte van de bestuursorganen van de stad en het door hen vastgestelde beleid.
14
+ 6. Ik zal van de overheidsmacht die mij is toevertrouwd geen misbruik maken.
15
+ 7. Ik zal zorgvuldig omgaan met informatie.
16
+ 8. Ik zal de geloofwaardigheid van het ambt niet schaden.
17
+ 9. Ik zal het vertrouwen, dat de burger in mij mag stellen, niet beschamen.
18
+ 10. Ik zal mij een zelfstandig oordeel vormen over de morele juistheid van mijn handelen.
19
+
20
+ Tevens verklaar ik dat ik bekend ben met de gedragscode voor ambtenaren van de gemeente Leiden en deze als leidraad zal hanteren. 
21
+
22
+ **Dat verklaar en beloof ik!**
23
+
24
+ Toen ik mocht starten bij de gemeente had ik weinig beeld bij wat een ambtenaar nou precies deed. Wat het betekende om te werken voor een gemeente. Mensen zeiden me: “Bij de gemeente, jij? Dat gaat jou toch allemaal veel te traag, dat is niks voor jou?!” Maar de inhoud van de functie sprak me ontzettend aan, het team waarin ik werd ontvangen was ongelofelijk warm en de rest zou ik nog ontdekken.  
25
+
26
+ <editorjs type='alignment' tag='p' alignment='center'> <b>Werk dat ertoe doet </b> </editorjs>
27
+
28
+ Toen ik mocht starten bij de gemeente had ik weinig beeld bij wat een ambtenaar nou precies deed. Wat het betekende om te werken voor een gemeente. Mensen zeiden me: “Bij de gemeente, jij? Dat gaat jou toch allemaal veel te traag, dat is niks voor jou?!” Maar de inhoud van de functie sprak me ontzettend aan, het team waarin ik werd ontvangen was ongelofelijk warm en de rest zou ik nog ontdekken. 
29
+
30
+ Later kon ik al deze mensen vertellen dat ik een geweldige baan had gevonden. Een baan waarvan mijn hart sneller ging kloppen. Ik mocht werk doen dat ertoe deed. Niks ging langzaam, eerder te snel. Nog nooit werkte ik in zo’n dynamische omgeving als een gemeente, geen dag is hetzelfde!  
31
+
32
+ Iedereen die mij spreekt, die weet: ik geniet van mijn werk. Ik mag met ontzettend fijne mensen werken en ik mag werk doen dat ertoe doet. Ik mag mij inzetten voor het welzijn en de rechten van burgers in Leiden. Net zoals ik heb beloofd.  
33
+
34
+ Wat ik wel merkte toen ik vanuit mijn functie met zoveel mogelijk partners en burgers in de stad ging praten, was dat er een flinke kloof was tussen de beleidswereld (gemeente, overheid) en de leefwereld van mensen, de wijken, de scholen, de plekken waarvoor het beleid werd gemaakt. Daar ervaarden mensen soms afstand, onbegrip en frustratie doordat de realiteit weerbarstig is en het beleid zwart-wit. Dit is natuurlijk niet iets Leids. Dit is landelijk zo, dit is internationaal zo. De afstand tussen overheid en ‘burgers’ lijkt groot, en bovendien ook steeds groter te worden. Regels, procedures, systemen. Maar ook simpelweg taal. Alsof het verschillende talen zijn, maar het zijn zeker verschillende belevingen...  
35
+
36
+ Wat in mijn eed en belofte niet echt scherp wordt, is het waartoe van mijn werk. Waartoe beloof ik te doen wat ik als ambtenaar ga doen? Zeker, trouw aan de wet, zorgvuldigheid, inzetten voor het welzijn, loyaal aan het stadsbestuur. Ja. Maar waartoe doe ik dit dan eigenlijk?  
37
+
38
+ Volgens AI bestaan “\[...] ambtenaren om het functioneren van de samenleving te waarborgen door het uitvoeren van overheidstaken, het ontwikkelen en uitvoeren van beleid, en het leveren van publieke diensten. Zij zorgen voor de handhaving van wetten, innen belastingen, verlenen vergunningen en ondersteunen politieke bestuurders.”  
39
+
40
+ Herkenbaar en zeker, dat doen we. Maar nogmaals, waartoe doen we dit? Anno 2026 is een ambtenaar toch zeker iemand die zich bewust is van het feit dat de functie bestaat bij de gratie van deze vorm van democratie? Dat het doel is om de inwoners te dienen, en dus ook nabij te zijn om zelf te ervaren dat je werk goed uitpakt, en dus dienend is aan het welzijn en de rechten van de burgers? Soms raak ik zelf zo overweldigd door de systemen waarin we allen ons werk proberen uit te voeren dat het eerder voelt alsof je trouw hebt beloofd te zijn aan Excel-sheets en processen van besluitvorming, dan aan je inzetten voor het welzijn van de inwoners. De praktijk van een ambtenaar is ook best weerbarstig…   
41
+
42
+ Tim ‘s Jongers  <a href="https://decorrespondent.nl/15957/beleid-wordt-beter-als-ambtenaren-achter-hun-laptop-vandaan-komen-daarom-een-pleidooi-voor-een-maatschappelijke-diensttijd/91ead8ad-ffb7-0a2a-15bf-316fe8e4578a" target="_blank">schreef eens dat beleid beter wordt als ambtenaren achter hun laptops vandaan komen </a> en onderdeel worden van de leefwereld waarvoor zij beleid maken. In Leiden kennen wij gelukkig verschillende werkprincipes die ons hierop wijzen, zo ook: wij werken van buiten (wat leeft in de stad) naar binnen (we maken beleid dat aansluit op wat leeft in de stad).  
43
+
44
+ <editorjs type='alignment' tag='h2' alignment='center'>Leidse Ambtenaren in de Praktijk  </editorjs>
45
+
46
+ Eén van de projecten die vanuit de Leidse Gelijke Kansen Aanpak is opgestart is de pilot ‘Ambtenaar in de Praktijk’. Via Ambtenaar in de Praktijk, doen ambtenaren van gemeente Leiden gerichte praktijkervaring op. Op die manier ervaren zij direct hoe beleid uitpakt in de praktijk en die kennis en ervaring brengen zij mee terug naar hun beleidsafdelingen.  
47
+
48
+ “Ik krijg door het meewerken in de praktijk van een brugfunctionaris meer inzicht in de problematieken die scholen binnenkomen. Ik realiseerde me dat problemen zichtbaar worden via het kind maar dat de problemen altijd verder reiken.”   —  Ambtenaar in de praktijk 
49
+
50
+ <editorjs type='alignment' tag='p' alignment='center'> <b>Gerichte praktijkervaring via partners in de stad  </b> </editorjs>
51
+
52
+ In aanloop naar deze pilot hebben we de standpunten van onze samenwerkingspartners geïnventariseerd en hen gevraagd: ‘stel dat we dit gaan doen, wat zou je dan waardevolle praktijkervaring vinden voor een ambtenaar en hoe kun je dat faciliteren?’  
53
+
54
+ Op die manier zijn we gekomen tot een soort menukaart met daarop min of meer kant-en-klare ideeën voor het opdoen van praktijkervaringen. Denk aan het meewerken in een buurthuis of buurtontmoetingsplek tijdens een inloop voor inwoners. Of denk aan het meewerken bij een speelgroep van JES Rijnland waar ouders met zeer jonge kinderen komen. Of misschien het meewerken bij een spreekuur rondom schulden. Of meewerken op een Leidse basisschool om zo meer mee te krijgen van wat er zoals speelt in en rond het onderwijs.   
55
+
56
+ “Bij de speelgroep merk ik dat er vaak sprake is van een taalbarrière. Ouders gebruiken de ontmoetingen bij een speelgroep dus ook om onderling informatie uit te wisselen. Het is een soort vindplaats. Ouders en de professional(s) van de speelgroep delen kennis en helpen elkaar dus via deze plek.” — Ambtenaar in de praktijk
57
+
58
+ <editorjs type='alignment' tag='p' alignment='center'> <b>De gemeente organiseert en faciliteert  </b> </editorjs>
59
+
60
+ Het is geweldig fijn dat gemeente Leiden haar ambtenaren hiervoor 32u op jaarbasis ruimte biedt. Op die manier geven we echt uitvoering aan ‘werken van buiten naar binnen’. De coördinatie van de pilot gebeurt vanuit de Leidse Gelijke Kansen Aanpak. Het is natuurlijk niet de bedoeling dat dit een verzwaring oplevert voor de partners in de stad. Het moet elkaar gaan versterken.  
61
+
62
+ “Het is alleen al prachtig dat we nu zo nabij kunnen zijn. De partners voelen zich gezien en gehoord in hun werk. En andersom kun je ook een inkijkje geven in hoe het dan binnen de gemeente loopt, waarom dingen soms wat tijd vragen. Of gewoon door te zeggen, “Ja, wij balen er ook enorm van dat dit nog wat rommelig loopt. We werken er hard aan om dit beter te maken.” Het vertrouwen waarmee de partners deze pilot in de stad hebben ontvangen is geweldig. En nu op naar verdere uitbreiding!”  — Ambtenaar in de praktijk
63
+
64
+ <editorjs type='alignment' tag='p' alignment='center'> <b>Ongelofelijk leerzame weken  </b> </editorjs>
65
+
66
+ De reacties in de stad zijn dan ook positief; de partnerorganisaties benadrukken het belang van praktijkervaring voor ambtenaren. Het is zeer leerzaam om in de praktijk te ervaren dat jouw beeld van hoe iets uitwerkt, misschien moet worden bijgesteld omdat de praktijk nu eenmaal weerbarstig is.  
67
+
68
+ “Op papier staat het mooi, maar in de praktijk valt het me op hoeveel je vraagt van partijen op het gebied van samenwerking en uitwisseling. Als je je handen dagelijks vol hebt aan het ondersteunen van inwoners, dan is het niet realistisch om te verwachten dat men ook nog zelf op zoek gaat naar allerlei samenwerkingen. Zonde, want je ziet wel hoeveel men onderling van die kennis zou kunnen profiteren. Zowel de professionals als inwoners.” — Ambtenaar in de praktijk  
69
+
70
+ <editorjs type='alignment' tag='p' alignment='center'> <b>De volgende ronde gaat in maart van start  </b> </editorjs>
71
+
72
+ De eerste ronde van deze mooie pilot is bijna afgerond. De betrokken ambtenaren ronden de pilot met elkaar af door de geleerde en ervaren lessen met elkaar om te zetten naar advies terug naar de beleidswereld. Onderling worden deze natuurlijk ook vast gedeeld met de volgende groep ambtenaren en met de betrokken partijen. Het is een prachtig leerproces. Wat leren wij van hen? Wat leren zij van ons? Wat betekent dit en hoe kunnen we dit leren omzetten in het daadwerkelijk verkleinen van die kloof tussen praktijk en beleid?   
73
+
74
+ Zo geeft een collega aan dat ze ziet hoe brieven in duidelijke taal vanuit de gemeente, toch vaak niet duidelijk genoeg lijken te zijn voor inwoners. Dat kun je niet weten, tenzij je spreekt met de inwoners zelf én met de professionals die hen ondersteunen.  
75
+
76
+ “Bij het Goede Buur spreekuur werk ik met gepassioneerde hulpverleners die de inwoner zo goed mogelijk willen helpen.” — Ambtenaar in de praktijk
77
+
78
+ Eén ding is zeker: dit zou onderdeel moeten kunnen zijn van je werk. Gemeente Leiden werkt graag kennisgericht, en via deze praktijkervaring maken we daar ook praktijkervaringskennisgericht werken van. Minimaal ieder jaar een week meewerken in de praktijk. De mouwen opstropen, doen, ondergaan en ervaren. Dit zou toch voor eenieder moeten gelden in elke positie waarbij jouw beleid invloed heeft op de ander? 
79
+
80
+ <editorjs type='alignment' tag='p' alignment='center'> <b>College in de Praktijk?  </b> </editorjs>
81
+
82
+ Pasgeleden opperde een collega dat we misschien ook moeten denken aan een uitbreiding van de pilot; ‘College in de Praktijk’, zodat na de verkiezingen het college van wethouders en burgemeester ook de Leidse praktijk echt kan ervaren. Een werkbezoek is namelijk echt iets heel anders als gewoon even ergens zelf de koffie zetten, stoelen uitklappen voor de vele inwoners die binnenkomen, samen soep koken, luisteren naar de verhalen van zij die even steun nodig hebben of vragen komen stellen. Zij die even warmte opzoeken omdat het thuis koud is. Of even stoom komen afblazen omdat ze niet als volwaardig worden gezien... 
83
+
84
+ “Heel eerlijk? Ik had geen idee. Ik wist het wel, maar ik realiseerde me het niet. Zelf voelen hoe het moet zijn om geen netwerk te hebben, om geen idee te hebben van de wereld om je heen. Zoveel te bieden, zo slim, getalenteerd. Zo betrokken en sociaal. Maar toch vast blijven zitten omdat je nu eenmaal de weg niet kent. Je denkt, dat hebben we allemaal goed ingeregeld! Maar in de praktijk zie je dat het niet zo makkelijk is...”  — Ambtenaar in de praktijk
85
+
86
+ ***
@@ -0,0 +1,22 @@
1
+ from pathlib import Path
2
+
3
+ from editorjs import EditorJS
4
+
5
+
6
+ def main():
7
+ # blog = Path("blog.md").read_text()
8
+
9
+ ejs = EditorJS.from_json(
10
+ '{"time":1770292823347,"blocks":[{"id":"QKelxSHz2Y","type":"paragraph","data":{"text":"rechts basis"},"tunes":{"alignmentTune":{"alignment":"right"}}},{"id":"IUX70yigzz","type":"paragraph","data":{"text":"links"},"tunes":{"alignmentTune":{"alignment":"left"}}},{"id":"SaWthD_Vlr","type":"paragraph","data":{"text":"<b>rechts duur</b>"},"tunes":{"alignmentTune":{"alignment":"right"}}}],"version":"2.30.7"}'
11
+ )
12
+
13
+ # if html = <editorjs type='alignment' tag='p' alignment='center'> <b>Werk dat ertoe doet </b> </editorjs>
14
+ # assert parse_html(html).type == 'alignment'
15
+
16
+ print(ejs.to_html()) # good
17
+ print(ejs.to_json()) # bad, fixme
18
+ # {"time": 1770292880600, "blocks": [{"type": "paragraph", "data": {"text": ""}, "tunes": {"alignmentTune": {"alignment": "right"}}}, {"type": "paragraph", "data": {"text": "links"}}, {"type": "paragraph", "data": {"text": "<b></b>"}, "tunes": {"alignmentTune": {"alignment": "right"}}}, {"type": "raw", "data": {"html": "</b></editorjs>"}}], "version": "2.30.6"}
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1 @@
1
+ __version__ = "2.6.1"
@@ -5,15 +5,15 @@ mdast to editorjs
5
5
  import abc
6
6
  import re
7
7
  import typing as t
8
- from html.parser import HTMLParser
9
8
  from urllib.parse import urlparse
10
9
 
10
+ import html2markdown
11
11
  import humanize
12
+ import lxml.html
12
13
  import markdown2
13
14
 
14
15
  from .exceptions import TODO, Unreachable
15
16
  from .types import EditorChildData, MDChildNode
16
- import html2markdown
17
17
 
18
18
 
19
19
  class EditorJSBlock(abc.ABC):
@@ -55,8 +55,6 @@ def process_styled_content(item: MDChildNode, strict: bool = True) -> str:
55
55
  """
56
56
  _type = item.get("type")
57
57
 
58
- print('process_styled_content', _type)
59
-
60
58
  html_wrappers = {
61
59
  "text": "{value}",
62
60
  "html": "{value}",
@@ -110,9 +108,9 @@ class HeadingBlock(EditorJSBlock):
110
108
  raise ValueError("Header level must be between 1 and 6.")
111
109
 
112
110
  if (
113
- tunes.get("alignmentTune")
114
- and (alignment := tunes["alignmentTune"].get("alignment"))
115
- and (alignment != "left")
111
+ tunes.get("alignmentTune")
112
+ and (alignment := tunes["alignmentTune"].get("alignment"))
113
+ and (alignment != "left")
116
114
  ):
117
115
  # can't just return regular HTML because then it will turn into a raw block
118
116
  return AlignmentBlock.to_markdown(
@@ -172,9 +170,9 @@ class ParagraphBlock(EditorJSBlock):
172
170
  tunes = data.get("tunes", {})
173
171
 
174
172
  if (
175
- tunes.get("alignmentTune")
176
- and (alignment := tunes["alignmentTune"].get("alignment"))
177
- and (alignment != "left")
173
+ tunes.get("alignmentTune")
174
+ and (alignment := tunes["alignmentTune"].get("alignment"))
175
+ and (alignment != "left")
178
176
  ):
179
177
  return AlignmentBlock.to_markdown(
180
178
  {
@@ -189,6 +187,18 @@ class ParagraphBlock(EditorJSBlock):
189
187
 
190
188
  return f"{text}\n\n"
191
189
 
190
+ @staticmethod
191
+ def _find_closing_editorjs_tag(nodes: list, start_idx: int) -> int | None:
192
+ """Find index of closing </editorjs> tag."""
193
+ for idx, node in enumerate(nodes[start_idx:], start=start_idx):
194
+ if (
195
+ node.get("type") == "html"
196
+ and node.get("value", "").strip() == "</editorjs>"
197
+ ):
198
+ return idx
199
+
200
+ return None
201
+
192
202
  @classmethod
193
203
  def to_json(cls, node: MDChildNode) -> list[dict]:
194
204
  result = []
@@ -212,14 +222,17 @@ class ParagraphBlock(EditorJSBlock):
212
222
 
213
223
  if child.get("value", "").endswith("/>"):
214
224
  # self-closing
215
- result.append(EditorJSCustom.to_json(node))
225
+ result.append(EditorJSCustom.to_json({"children": [child]}))
216
226
  else:
217
- # <editorjs>something</editorjs> = 3 children
218
- result.extend(
219
- EditorJSCustom.to_json({"children": nodes[idx: idx + 2]})
227
+ # <editorjs>...</editorjs> may include nested HTML; find the closing tag
228
+ end_idx = cls._find_closing_editorjs_tag(nodes, idx + 1)
229
+ children_slice = (
230
+ nodes[idx : idx + 2]
231
+ if end_idx is None
232
+ else nodes[idx : end_idx + 1]
220
233
  )
221
-
222
- skip = 2
234
+ skip = 2 if end_idx is None else end_idx - idx
235
+ result.extend(EditorJSCustom.to_json({"children": children_slice}))
223
236
 
224
237
  continue
225
238
 
@@ -398,7 +411,7 @@ class CodeBlock(EditorJSBlock):
398
411
  @classmethod
399
412
  def to_markdown(cls, data: EditorChildData) -> str:
400
413
  code = data.get("code", "")
401
- return f"```\n" f"{code}" f"\n```\n"
414
+ return f"```\n{code}\n```\n"
402
415
 
403
416
  @classmethod
404
417
  def to_json(cls, node: MDChildNode) -> list[dict]:
@@ -457,9 +470,9 @@ class ImageBlock(EditorJSBlock):
457
470
  border = node.get("border") or ""
458
471
 
459
472
  return f"""
460
- <div class="ce-block {stretched and 'ce-block--stretched'}">
473
+ <div class="ce-block {stretched and "ce-block--stretched"}">
461
474
  <div class="ce-block__content">
462
- <div class="cdx-block image-tool image-tool--filled {background and 'image-tool--withBackground'} {stretched and 'image-tool--stretched'} {border and 'image-tool--withBorder'}">
475
+ <div class="cdx-block image-tool image-tool--filled {background and "image-tool--withBackground"} {stretched and "image-tool--stretched"} {border and "image-tool--withBorder"}">
463
476
  <div class="image-tool__image">
464
477
  <figure>
465
478
  <img class="image-tool__image-picture" src="{url}" title="{caption}" alt="{caption}">
@@ -514,7 +527,6 @@ class QuoteBlock(EditorJSBlock):
514
527
 
515
528
  @block("raw", "html")
516
529
  class RawBlock(EditorJSBlock):
517
-
518
530
  @classmethod
519
531
  def to_markdown(cls, data: EditorChildData) -> str:
520
532
  text = data.get("html", "")
@@ -537,7 +549,6 @@ class RawBlock(EditorJSBlock):
537
549
 
538
550
  @block("table")
539
551
  class TableBlock(EditorJSBlock):
540
-
541
552
  @classmethod
542
553
  def to_markdown(cls, data: EditorChildData) -> str:
543
554
  """
@@ -656,7 +667,6 @@ class LinkBlock(EditorJSBlock):
656
667
 
657
668
  @block("attaches")
658
669
  class AttachmentBlock(EditorJSBlock):
659
-
660
670
  @classmethod
661
671
  def to_markdown(cls, data: EditorChildData) -> str:
662
672
  title = data.get("title", "")
@@ -721,7 +731,7 @@ class AttachmentBlock(EditorJSBlock):
721
731
  </div>
722
732
  {file_size}
723
733
  </div>
724
- <a class="cdx-attaches__download-button" href="{node.get('file', '')}" target="_blank" rel="nofollow noindex noreferrer" title="{node.get('name', '')}">
734
+ <a class="cdx-attaches__download-button" href="{node.get("file", "")}" target="_blank" rel="nofollow noindex noreferrer" title="{node.get("name", "")}">
725
735
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M7 10L11.8586 14.8586C11.9367 14.9367 12.0633 14.9367 12.1414 14.8586L17 10"></path></svg>
726
736
  </a>
727
737
  </div>
@@ -773,7 +783,6 @@ class AlignmentBlock(EditorJSBlock):
773
783
 
774
784
  @block("embed")
775
785
  class EmbedBlock(EditorJSBlock):
776
-
777
786
  @classmethod
778
787
  def to_markdown(cls, data: EditorChildData) -> str:
779
788
  service = data.get("service", "")
@@ -802,20 +811,6 @@ class EmbedBlock(EditorJSBlock):
802
811
  ### end blocks
803
812
 
804
813
 
805
- class AttributeParser(HTMLParser):
806
- def __init__(self):
807
- super().__init__()
808
- self.attributes = {}
809
- self.data = None
810
-
811
- def handle_starttag(self, tag, attrs):
812
- # Collect attributes when the tag is encountered
813
- self.attributes = dict(attrs)
814
-
815
- def handle_data(self, data):
816
- self.data = data
817
-
818
-
819
814
  class EditorJSCustom(EditorJSBlock, markdown2.Extra):
820
815
  """
821
816
  Special type of block to deal with custom attributes.
@@ -828,10 +823,29 @@ class EditorJSCustom(EditorJSBlock, markdown2.Extra):
828
823
 
829
824
  @classmethod
830
825
  def parse_html(cls, html: str):
831
- parser = AttributeParser()
832
- parser.feed(html)
826
+ """
827
+ Extract attributes from the outermost HTML element and return its inner HTML.
828
+
829
+ This function parses the provided markup, identifies the root element,
830
+ returns its attributes as a dictionary, and serializes all direct child
831
+ nodes back into an HTML string.
832
+
833
+ Args:
834
+ html: A string containing a single root HTML element.
835
+
836
+ Returns:
837
+ A tuple of:
838
+ - dict[str, str]: Attributes of the root element
839
+ - str: Inner HTML of the root element
840
+ """
841
+ root = lxml.html.fromstring(html)
842
+
843
+ attributes = dict(root.attrib)
844
+ inner_html = "".join(
845
+ lxml.html.tostring(child, encoding="unicode") for child in root
846
+ )
833
847
 
834
- return parser.attributes, parser.data
848
+ return attributes, inner_html
835
849
 
836
850
  @classmethod
837
851
  def to_markdown(cls, data: EditorChildData) -> str:
@@ -846,6 +860,7 @@ class EditorJSCustom(EditorJSBlock, markdown2.Extra):
846
860
  handler = BLOCKS.get(_type)
847
861
 
848
862
  if not handler:
863
+ raise ValueError(f"debug: {attrs = } {body = }") # fixme
849
864
  raise ValueError(f"Unknown custom type {_type}")
850
865
 
851
866
  return handler, attrs
@@ -100,13 +100,11 @@ class EditorJS:
100
100
  try:
101
101
  blocks.extend(block.to_json(child))
102
102
  except Exception as e:
103
-
104
103
  warnings.warn(
105
104
  "to_json: Oh oh, unexpected block failure!",
106
105
  category=RuntimeWarning,
107
106
  source=e,
108
107
  )
109
- raise e
110
108
  # if isinstance(e, TODO):
111
109
  # raise e
112
110
 
@@ -31,6 +31,7 @@ dependencies = [
31
31
  "markdown2", # markdown -> html
32
32
  "html2markdown", # html -> markdown
33
33
  "humanize",
34
+ "lxml",
34
35
  ]
35
36
 
36
37
  [project.optional-dependencies]
@@ -2,6 +2,7 @@ import json
2
2
  import textwrap
3
3
 
4
4
  from editorjs import EditorJS
5
+ from editorjs.blocks import EditorJSCustom
5
6
 
6
7
  EXAMPLE_MD = textwrap.dedent("""
7
8
  # Heading
@@ -185,7 +186,8 @@ asdfsdajgdsjaklgkjds
185
186
 
186
187
 
187
188
  def test_code():
188
- e = EditorJS.from_markdown(textwrap.dedent("""
189
+ e = EditorJS.from_markdown(
190
+ textwrap.dedent("""
189
191
  Read code:
190
192
 
191
193
  ```
@@ -193,7 +195,8 @@ def test_code():
193
195
  ```
194
196
 
195
197
  End of code
196
- """))
198
+ """)
199
+ )
197
200
 
198
201
  blocks = json.loads(e.to_json())
199
202
 
@@ -266,6 +269,7 @@ def test_figcaption():
266
269
 
267
270
  assert "figcaption" in html
268
271
 
272
+
269
273
  def test_bold():
270
274
  js = """{"time":1742475802066,"blocks":[{"id":"v_Kc51dnJH","type":"paragraph","data":{"text":"Deze tekst is <b>half bold</b> en half niet"},"tunes":{"alignmentTune":{"alignment":"left"}}},{"id":"q_fkuEFcY5","type":"paragraph","data":{"text":"<b>Deze tekst is heel bold</b>"},"tunes":{"alignmentTune":{"alignment":"left"}}}],"version":"2.30.7"}"""
271
275
 
@@ -291,3 +295,81 @@ def test_quotes():
291
295
  e.to_markdown(),
292
296
  )
293
297
 
298
+
299
+ # test case based on issue HETNIEUWELEIDEN-019c28cd-56b8-7778-be0c-60fb9e930638
300
+
301
+
302
+ def test_nonrecusrive_attr_parser():
303
+ html = "<div type='outer'><div type='inner'>contents</div></div>"
304
+
305
+ attributes, data = EditorJSCustom.parse_html(html)
306
+
307
+ assert attributes == {"type": "outer"}
308
+ assert data == '<div type="inner">contents</div>'
309
+
310
+
311
+ def test_editorjs_alignment_tag():
312
+ """Test that editorjs alignment tags with nested HTML are parsed correctly."""
313
+ md_input = "<editorjs type='alignment' tag='p' alignment='center'> <b>Werk dat ertoe doet </b> </editorjs>"
314
+
315
+ e = EditorJS.from_markdown(md_input)
316
+
317
+ print("MDAST:", e.to_mdast())
318
+ print("Markdown:", e.to_markdown())
319
+ print("JSON:", e.to_json())
320
+
321
+ html = e.to_html()
322
+ print("HTML:", html)
323
+
324
+ # The type should be detected as 'alignment' (or mapped to 'paragraph' with alignment tune)
325
+ blocks = json.loads(e.to_json())
326
+
327
+ assert blocks
328
+
329
+ assert "<b>Werk dat ertoe doet" in html
330
+ assert "<editorjs" not in html
331
+ assert "<code" not in html
332
+
333
+
334
+ def test_alignment_with_nested_bold_roundtrip():
335
+ """
336
+ Test that alignment tags preserve nested bold HTML through JSON roundtrip.
337
+
338
+ Regression test for issue where nested HTML in alignment tags causes:
339
+ - Text content to disappear
340
+ - Malformed closing tags to appear as separate raw blocks
341
+ """
342
+ # Input: three paragraphs with different alignments, one with nested bold
343
+ input_json = r"""{"time":1770292823347,"blocks":[{"id":"QKelxSHz2Y","type":"paragraph","data":{"text":"rechts basis"},"tunes":{"alignmentTune":{"alignment":"right"}}},{"id":"IUX70yigzz","type":"paragraph","data":{"text":"links"},"tunes":{"alignmentTune":{"alignment":"left"}}},{"id":"SaWthD_Vlr","type":"paragraph","data":{"text":"<b>rechts duur</b>"},"tunes":{"alignmentTune":{"alignment":"right"}}}],"version":"2.30.7"}"""
344
+ # faulty output: {"time": 1770292880600, "blocks": [{"type": "paragraph", "data": {"text": ""}, "tunes": {"alignmentTune": {"alignment": "right"}}}, {"type": "paragraph", "data": {"text": "links"}}, {"type": "paragraph", "data": {"text": "<b></b>"}, "tunes": {"alignmentTune": {"alignment": "right"}}}, {"type": "raw", "data": {"html": "</b></editorjs>"}}], "version": "2.30.6"}
345
+
346
+ e = EditorJS.from_json(input_json)
347
+
348
+ # Convert through markdown and back to JSON
349
+ md = e.to_markdown()
350
+ print("Markdown output:", md)
351
+
352
+ e2 = EditorJS.from_markdown(md)
353
+ output_json = json.loads(e2.to_json())
354
+
355
+ output_str = json.dumps(output_json, indent=2)
356
+ print("Output JSON:", output_str)
357
+
358
+ # Should have exactly 3 blocks (not 4 with a malformed raw block)
359
+ assert len(output_json["blocks"]) == 3
360
+
361
+ # All should be paragraphs (not raw blocks)
362
+ for block in output_json["blocks"]:
363
+ assert block["type"] == "paragraph"
364
+
365
+ # Third block should preserve the bold content
366
+ assert (
367
+ "<b>rechts duur</b>" in output_json["blocks"][2]["data"]["text"]
368
+ or "<b>rechts duur </b>" in output_json["blocks"][2]["data"]["text"]
369
+ )
370
+
371
+ # Third block should preserve alignment
372
+ assert output_json["blocks"][2]["tunes"]["alignmentTune"]["alignment"] == "right"
373
+
374
+ assert "raw" not in output_str
375
+ assert "html" not in output_str
@@ -1 +0,0 @@
1
- __version__ = "2.5.0a4"
File without changes