Coverage for pyeditorjs/blocks.py: 74%

146 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-10-31 13:29 +0100

1import abc 

2import typing as t 

3from dataclasses import dataclass 

4 

5import bleach 

6 

7from .exceptions import EditorJsParseError 

8 

9__all__ = [ 

10 "block", 

11 "BLOCKS_MAP", 

12 "EditorJsBlock", 

13] 

14 

15 

16def _sanitize(html: str) -> str: 

17 return bleach.clean( 

18 html, 

19 tags=["b", "i", "u", "a", "mark", "code"], 

20 attributes=["class", "data-placeholder", "href"], 

21 ) 

22 

23 

24BLOCKS_MAP: t.Dict[str, t.Type["EditorJsBlock"]] = { 

25 # 'header': HeaderBlock, 

26 # 'paragraph': ParagraphBlock, 

27 # 'list': ListBlock, 

28 # 'delimiter': DelimiterBlock, 

29 # 'image': ImageBlock, 

30} 

31 

32 

33def block(_type: str): 

34 def wrapper(cls: t.Type["EditorJsBlock"]): 

35 BLOCKS_MAP[_type] = cls 

36 return cls 

37 

38 return wrapper 

39 

40 

41@dataclass 

42class EditorJsBlock(abc.ABC): 

43 """ 

44 A generic parsed Editor.js block 

45 """ 

46 

47 _data: dict 

48 """The raw JSON data of the entire block""" 

49 

50 @property 

51 def id(self) -> t.Optional[str]: 

52 """ 

53 Returns ID of the block, generated client-side. 

54 """ 

55 

56 return self._data.get("id", None) 

57 

58 @property 

59 def type(self) -> t.Optional[str]: 

60 """ 

61 Returns the type of the block. 

62 """ 

63 

64 return self._data.get("type", None) 

65 

66 @property 

67 def data(self) -> dict: 

68 """ 

69 Returns the actual block data. 

70 """ 

71 

72 return self._data.get("data", {}) 

73 

74 @abc.abstractmethod 

75 def html(self, sanitize: bool = False) -> str: 

76 """ 

77 Returns the HTML representation of the block. 

78 

79 ### Parameters: 

80 - `sanitize` - if `True`, then the block's text/contents will be sanitized. 

81 """ 

82 

83 raise NotImplementedError() 

84 

85 

86@block("header") 

87class HeaderBlock(EditorJsBlock): 

88 VALID_HEADER_LEVEL_RANGE = range(1, 7) 

89 """Valid range for header levels. Default is `range(1, 7)` - so, `0` - `6`.""" 

90 

91 @property 

92 def text(self) -> str: 

93 """ 

94 Returns the header's text. 

95 """ 

96 

97 return self.data.get("text", "") 

98 

99 @property 

100 def level(self) -> int: 

101 """ 

102 Returns the header's level (`0` - `6`). 

103 """ 

104 

105 _level = self.data.get("level", 1) 

106 

107 if not isinstance(_level, int) or _level not in self.VALID_HEADER_LEVEL_RANGE: 

108 raise EditorJsParseError(f"`{_level}` is not a valid header level.") 

109 

110 return _level 

111 

112 def html(self, sanitize: bool = False) -> str: 

113 text = self.text 

114 if sanitize: 

115 text = _sanitize(text) 

116 return rf'<h{self.level} class="cdx-block ce-header">{text}</h{self.level}>' 

117 

118 

119@block("paragraph") 

120class ParagraphBlock(EditorJsBlock): 

121 @property 

122 def text(self) -> str: 

123 """ 

124 The text content of the paragraph. 

125 """ 

126 

127 return self.data.get("text", "") 

128 

129 def html(self, sanitize: bool = False) -> str: 

130 return rf'<p class="cdx-block ce-paragraph">{_sanitize(self.text) if sanitize else self.text}</p>' 

131 

132 

133@block("list") 

134class ListBlock(EditorJsBlock): 

135 VALID_STYLES = ("unordered", "ordered") 

136 """Valid list order styles.""" 

137 

138 @property 

139 def style(self) -> t.Optional[str]: 

140 """ 

141 The style of the list. Can be `ordered` or `unordered`. 

142 """ 

143 

144 return self.data.get("style", None) 

145 

146 @property 

147 def items(self) -> t.List[str]: 

148 """ 

149 Returns the list's items, in raw format. 

150 """ 

151 

152 return self.data.get("items", []) 

153 

154 def html(self, sanitize: bool = False) -> str: 

155 if self.style not in self.VALID_STYLES: 

156 raise EditorJsParseError(f"`{self.style}` is not a valid list style.") 

157 

158 _items = [ 

159 f"<li>{_sanitize(item) if sanitize else item}</li>" for item in self.items 

160 ] 

161 _type = "ul" if self.style == "unordered" else "ol" 

162 _items_html = "".join(_items) 

163 

164 return rf'<{_type} class="cdx-block cdx-list cdx-list--{self.style}">{_items_html}</{_type}>' 

165 

166 

167@block("delimiter") 

168class DelimiterBlock(EditorJsBlock): 

169 def html(self, sanitize: bool = False) -> str: 

170 return r'<div class="cdx-block ce-delimiter"></div>' 

171 

172 

173@block("image") 

174class ImageBlock(EditorJsBlock): 

175 @property 

176 def file_url(self) -> str: 

177 """ 

178 URL of the image file. 

179 """ 

180 

181 return self.data.get("file", {}).get("url", "") 

182 

183 @property 

184 def caption(self) -> str: 

185 """ 

186 The image's caption. 

187 """ 

188 

189 return self.data.get("caption", "") 

190 

191 @property 

192 def with_border(self) -> bool: 

193 """ 

194 Whether the image has a border. 

195 """ 

196 

197 return self.data.get("withBorder", False) 

198 

199 @property 

200 def stretched(self) -> bool: 

201 """ 

202 Whether the image is stretched. 

203 """ 

204 

205 return self.data.get("stretched", False) 

206 

207 @property 

208 def with_background(self) -> bool: 

209 """ 

210 Whether the image has a background. 

211 """ 

212 

213 return self.data.get("withBackground", False) 

214 

215 def html(self, sanitize: bool = False) -> str: 

216 if self.file_url.startswith("data:image/"): 

217 _img = self.file_url 

218 else: 

219 _img = _sanitize(self.file_url) if sanitize else self.file_url 

220 

221 parts = [ 

222 rf'<div class="cdx-block image-tool image-tool--filled {"image-tool--stretched" if self.stretched else ""} {"image-tool--withBorder" if self.with_border else ""} {"image-tool--withBackground" if self.with_background else ""}">' 

223 r'<div class="image-tool__image">', 

224 r'<div class="image-tool__image-preloader"></div>', 

225 rf'<img class="image-tool__image-picture" src="{_img}"/>', 

226 r"</div>" 

227 rf'<div class="image-tool__caption" data-placeholder="{_sanitize(self.caption) if sanitize else self.caption}"></div>' 

228 r"</div>" 

229 r"</div>", 

230 ] 

231 

232 return "".join(parts) 

233 

234 

235@block("quote") 

236class QuoteBlock(EditorJsBlock): 

237 def html(self, sanitize: bool = False) -> str: 

238 quote = self.data.get("text", "") 

239 caption = self.data.get("caption", "") 

240 if sanitize: 

241 quote = _sanitize(quote) 

242 caption = _sanitize(caption) 

243 _alignment = self.data.get("alignment", "left") # todo 

244 return f""" 

245 <blockquote class="cdx-block cdx-quote"> 

246 <div class="cdx-input cdx-quote__text">{quote}</div> 

247 <cite class="cdx-input cdx-quote__caption">{caption}</cite> 

248 </blockquote> 

249 """ 

250 

251 

252@block("table") 

253class TableBlock(EditorJsBlock): 

254 def html(self, sanitize: bool = False) -> str: 

255 content = self.data.get("content", []) 

256 _stretched = self.data.get("stretched", False) # todo 

257 _with_headings = self.data.get("withHeadings", False) # todo 

258 

259 html_table = '<table class="tc-table">' 

260 

261 # Add content rows 

262 for row in content: 

263 html_table += '<tr class="tc-row">' 

264 for cell in row: 

265 html_table += ( 

266 f'<td class="tc-cell">{_sanitize(cell) if sanitize else cell}</td>' 

267 ) 

268 html_table += "</tr>" 

269 

270 html_table += "</table>" 

271 return html_table 

272 

273 

274@block("code") 

275class CodeBlock(EditorJsBlock): 

276 def html(self, sanitize: bool = False) -> str: 

277 code = self.data.get("code", "") 

278 if sanitize: 

279 code = _sanitize(code) 

280 return f""" 

281 <code class="ce-code__textarea cdx-input" data-empty="false">{code}</code> 

282 """ 

283 

284 

285@block("warning") 

286class WarningBlock(EditorJsBlock): 

287 def html(self, sanitize: bool = False) -> str: 

288 title = self.data.get("title", "") 

289 message = self.data.get("message", "") 

290 

291 if sanitize: 

292 title = _sanitize(title) 

293 message = _sanitize(message) 

294 

295 return f""" 

296 <div class="cdx-block cdx-warning"> 

297 <div class="cdx-input cdx-warning__title">{title}</div> 

298 <div class="cdx-input cdx-warning__message">{message}</div> 

299 </div> 

300 """ 

301 

302 

303@block("raw") 

304class RawBlock(EditorJsBlock): 

305 def html(self, sanitize: bool = False) -> str: 

306 html = self.data.get("html", "") 

307 if sanitize: 

308 html = _sanitize(html) 

309 return html