cnotebook 1.2.0__py3-none-any.whl → 2.1.1__py3-none-any.whl
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.
- cnotebook/__init__.py +365 -67
- cnotebook/align.py +231 -167
- cnotebook/context.py +50 -18
- cnotebook/grid/__init__.py +56 -0
- cnotebook/grid/grid.py +1655 -0
- cnotebook/helpers.py +147 -15
- cnotebook/ipython_ext.py +0 -3
- cnotebook/marimo_ext.py +67 -0
- cnotebook/pandas_ext.py +760 -514
- cnotebook/polars_ext.py +1237 -0
- cnotebook/render.py +0 -195
- cnotebook-2.1.1.dist-info/METADATA +338 -0
- cnotebook-2.1.1.dist-info/RECORD +16 -0
- {cnotebook-1.2.0.dist-info → cnotebook-2.1.1.dist-info}/WHEEL +1 -1
- cnotebook-1.2.0.dist-info/METADATA +0 -280
- cnotebook-1.2.0.dist-info/RECORD +0 -13
- {cnotebook-1.2.0.dist-info → cnotebook-2.1.1.dist-info}/licenses/LICENSE +0 -0
- {cnotebook-1.2.0.dist-info → cnotebook-2.1.1.dist-info}/top_level.txt +0 -0
cnotebook/__init__.py
CHANGED
|
@@ -1,102 +1,400 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CNotebook - Ergonomic chemistry visualization in notebooks.
|
|
3
|
+
|
|
4
|
+
Auto-detects available backends (Pandas/Polars) and environments (Jupyter/Marimo).
|
|
5
|
+
Only requires openeye-toolkits; all other dependencies are optional.
|
|
6
|
+
"""
|
|
1
7
|
import logging
|
|
2
|
-
from .pandas_ext import render_dataframe
|
|
3
|
-
from .context import cnotebook_context
|
|
4
|
-
from .ipython_ext import register_ipython_formatters as _register_ipython_formatters
|
|
5
|
-
from .render import render_molecule_grid
|
|
6
8
|
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
from cnotebook.pandas_ext import register_pandas_formatters as _register_pandas_formatters
|
|
9
|
+
# Required imports (openeye-toolkits)
|
|
10
|
+
from openeye import oechem, oedepict
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
# Core functionality that doesn't depend on backends
|
|
13
|
+
from .context import cnotebook_context, CNotebookContext
|
|
14
|
+
from .helpers import highlight_smarts
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
__version__ = '2.1.1'
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
try:
|
|
18
|
-
from IPython import get_ipython
|
|
19
|
-
shell = get_ipython().__class__.__name__
|
|
18
|
+
# Configure logging first
|
|
19
|
+
log = logging.getLogger("cnotebook")
|
|
20
20
|
|
|
21
|
-
if shell == 'ZMQInteractiveShell':
|
|
22
|
-
return True # Jupyter notebook or qtconsole
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
class LevelSpecificFormatter(logging.Formatter):
|
|
23
|
+
"""A logging formatter that uses level-specific formats.
|
|
24
|
+
|
|
25
|
+
Uses a simple format for INFO and above, and includes the level name
|
|
26
|
+
for DEBUG messages to help distinguish debug output.
|
|
26
27
|
|
|
28
|
+
:cvar NORMAL_FORMAT: Format string for INFO and above.
|
|
29
|
+
:cvar DEBUG_FORMAT: Format string for DEBUG level.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
NORMAL_FORMAT = "%(message)s"
|
|
33
|
+
DEBUG_FORMAT = "%(levelname)s: %(message)s"
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
"""Create the formatter with the normal format as default."""
|
|
37
|
+
super().__init__(fmt=self.NORMAL_FORMAT, datefmt=None, style='%')
|
|
38
|
+
|
|
39
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
40
|
+
if record.levelno == logging.DEBUG:
|
|
41
|
+
self._style._fmt = self.DEBUG_FORMAT
|
|
27
42
|
else:
|
|
28
|
-
|
|
43
|
+
self._style._fmt = self.NORMAL_FORMAT
|
|
44
|
+
return logging.Formatter.format(self, record)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Configure handler
|
|
48
|
+
_handler = logging.StreamHandler()
|
|
49
|
+
_handler.setLevel(logging.DEBUG)
|
|
50
|
+
_handler.setFormatter(LevelSpecificFormatter())
|
|
51
|
+
log.addHandler(_handler)
|
|
52
|
+
log.setLevel(logging.INFO)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def enable_debugging():
|
|
56
|
+
"""Convenience function for enabling the debug log."""
|
|
57
|
+
log.setLevel(logging.DEBUG)
|
|
29
58
|
|
|
30
|
-
except Exception:
|
|
31
|
-
return False # Probably standard Python interpreter
|
|
32
59
|
|
|
60
|
+
########################################################################################################################
|
|
61
|
+
# Environment Information
|
|
62
|
+
########################################################################################################################
|
|
63
|
+
|
|
64
|
+
class CNotebookEnvInfo:
|
|
65
|
+
"""Environment information for CNotebook.
|
|
66
|
+
|
|
67
|
+
This class provides read-only access to detected backend and environment
|
|
68
|
+
availability. A singleton instance is created at module load time and
|
|
69
|
+
can be retrieved via :func:`get_env`.
|
|
70
|
+
|
|
71
|
+
All properties are read-only to ensure consistency throughout the
|
|
72
|
+
application lifecycle. Availability is determined by checking if the
|
|
73
|
+
version string is non-empty.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
pandas_version: str,
|
|
79
|
+
polars_version: str,
|
|
80
|
+
ipython_version: str,
|
|
81
|
+
marimo_version: str,
|
|
82
|
+
molgrid_available: bool,
|
|
83
|
+
is_jupyter_notebook: bool,
|
|
84
|
+
is_marimo_notebook: bool,
|
|
85
|
+
):
|
|
86
|
+
"""Create environment info (typically called once at module load).
|
|
87
|
+
|
|
88
|
+
:param pandas_version: Detected Pandas version string, or empty if unavailable.
|
|
89
|
+
:param polars_version: Detected Polars version string, or empty if unavailable.
|
|
90
|
+
:param ipython_version: Detected IPython version string, or empty if unavailable.
|
|
91
|
+
:param marimo_version: Detected Marimo version string, or empty if unavailable.
|
|
92
|
+
:param molgrid_available: Whether MolGrid widget dependencies are available.
|
|
93
|
+
:param is_jupyter_notebook: Whether running in a Jupyter notebook environment.
|
|
94
|
+
:param is_marimo_notebook: Whether running in a Marimo notebook environment.
|
|
95
|
+
"""
|
|
96
|
+
self._pandas_version = pandas_version
|
|
97
|
+
self._polars_version = polars_version
|
|
98
|
+
self._ipython_version = ipython_version
|
|
99
|
+
self._marimo_version = marimo_version
|
|
100
|
+
self._molgrid_available = molgrid_available
|
|
101
|
+
self._is_jupyter_notebook = is_jupyter_notebook
|
|
102
|
+
self._is_marimo_notebook = is_marimo_notebook
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def pandas_available(self) -> bool:
|
|
106
|
+
"""Whether Pandas and OEPandas are available."""
|
|
107
|
+
return bool(self._pandas_version)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def pandas_version(self) -> str:
|
|
111
|
+
"""Pandas version string, or empty string if not available."""
|
|
112
|
+
return self._pandas_version
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def polars_available(self) -> bool:
|
|
116
|
+
"""Whether Polars and OEPolars are available."""
|
|
117
|
+
return bool(self._polars_version)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def polars_version(self) -> str:
|
|
121
|
+
"""Polars version string, or empty string if not available."""
|
|
122
|
+
return self._polars_version
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def ipython_available(self) -> bool:
|
|
126
|
+
"""Whether IPython is available and active."""
|
|
127
|
+
return bool(self._ipython_version)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def ipython_version(self) -> str:
|
|
131
|
+
"""IPython version string, or empty string if not available."""
|
|
132
|
+
return self._ipython_version
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def marimo_available(self) -> bool:
|
|
136
|
+
"""Whether Marimo is available and running in notebook mode."""
|
|
137
|
+
return bool(self._marimo_version)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def marimo_version(self) -> str:
|
|
141
|
+
"""Marimo version string, or empty string if not available."""
|
|
142
|
+
return self._marimo_version
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def molgrid_available(self) -> bool:
|
|
146
|
+
"""Whether MolGrid is available (requires anywidget)."""
|
|
147
|
+
return self._molgrid_available
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_jupyter_notebook(self) -> bool:
|
|
151
|
+
"""Whether running in a Jupyter notebook environment."""
|
|
152
|
+
return self._is_jupyter_notebook
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def is_marimo_notebook(self) -> bool:
|
|
156
|
+
"""Whether running in a Marimo notebook environment."""
|
|
157
|
+
return self._is_marimo_notebook
|
|
158
|
+
|
|
159
|
+
def __repr__(self) -> str:
|
|
160
|
+
return (
|
|
161
|
+
f"CNotebookEnvInfo("
|
|
162
|
+
f"pandas={self.pandas_available} ({self._pandas_version}), "
|
|
163
|
+
f"polars={self.polars_available} ({self._polars_version}), "
|
|
164
|
+
f"ipython={self.ipython_available} ({self._ipython_version}), "
|
|
165
|
+
f"marimo={self.marimo_available} ({self._marimo_version}), "
|
|
166
|
+
f"molgrid={self._molgrid_available}, "
|
|
167
|
+
f"jupyter={self._is_jupyter_notebook}, "
|
|
168
|
+
f"marimo_nb={self._is_marimo_notebook})"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _detect_environment() -> CNotebookEnvInfo:
|
|
173
|
+
"""Detect available backends and environments.
|
|
174
|
+
|
|
175
|
+
:returns: CNotebookEnvInfo instance with detection results.
|
|
176
|
+
"""
|
|
177
|
+
pandas_version = ""
|
|
178
|
+
polars_version = ""
|
|
179
|
+
ipython_version = ""
|
|
180
|
+
marimo_version = ""
|
|
181
|
+
molgrid_available = False
|
|
182
|
+
is_jupyter = False
|
|
183
|
+
is_marimo = False
|
|
184
|
+
|
|
185
|
+
# Detect molgrid (requires anywidget)
|
|
186
|
+
try:
|
|
187
|
+
from cnotebook.grid import molgrid, MolGrid
|
|
188
|
+
molgrid_available = True
|
|
189
|
+
except ImportError:
|
|
190
|
+
pass
|
|
33
191
|
|
|
34
|
-
|
|
35
|
-
|
|
192
|
+
# Detect pandas/oepandas
|
|
193
|
+
try:
|
|
194
|
+
import pandas as pd
|
|
195
|
+
import oepandas as oepd
|
|
196
|
+
pandas_version = pd.__version__
|
|
197
|
+
except ImportError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
# Detect polars/oepolars
|
|
201
|
+
try:
|
|
202
|
+
import polars as pl
|
|
203
|
+
import oepolars as oeplr
|
|
204
|
+
polars_version = pl.__version__
|
|
205
|
+
except ImportError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
# Detect iPython
|
|
209
|
+
try:
|
|
210
|
+
import IPython
|
|
211
|
+
# noinspection PyProtectedMember
|
|
212
|
+
from IPython import get_ipython
|
|
213
|
+
ipy = get_ipython()
|
|
214
|
+
if ipy is not None:
|
|
215
|
+
ipython_version = IPython.__version__
|
|
216
|
+
# Check if running in Jupyter notebook
|
|
217
|
+
is_jupyter = ipy.__class__.__name__ == 'ZMQInteractiveShell'
|
|
218
|
+
except (ImportError, Exception):
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
# Detect Marimo
|
|
36
222
|
try:
|
|
37
|
-
# noinspection PyUnresolvedReferences
|
|
38
223
|
import marimo as mo
|
|
39
|
-
|
|
224
|
+
if mo.running_in_notebook():
|
|
225
|
+
marimo_version = mo.__version__
|
|
226
|
+
is_marimo = True
|
|
227
|
+
except (ImportError, Exception):
|
|
228
|
+
# Marimo raises exception if not running in notebook context
|
|
229
|
+
pass
|
|
40
230
|
|
|
41
|
-
|
|
42
|
-
|
|
231
|
+
return CNotebookEnvInfo(
|
|
232
|
+
pandas_version=pandas_version,
|
|
233
|
+
polars_version=polars_version,
|
|
234
|
+
ipython_version=ipython_version,
|
|
235
|
+
marimo_version=marimo_version,
|
|
236
|
+
molgrid_available=molgrid_available,
|
|
237
|
+
is_jupyter_notebook=is_jupyter,
|
|
238
|
+
is_marimo_notebook=is_marimo,
|
|
239
|
+
)
|
|
43
240
|
|
|
44
241
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
if is_jupyter_notebook():
|
|
48
|
-
_register_ipython_formatters()
|
|
49
|
-
_register_pandas_formatters()
|
|
242
|
+
# Initialize environment detection at module load (singleton instance)
|
|
243
|
+
_env_info: CNotebookEnvInfo = _detect_environment()
|
|
50
244
|
|
|
51
|
-
elif is_marimo_notebook():
|
|
52
|
-
from . import marimo_ext
|
|
53
245
|
|
|
54
|
-
|
|
55
|
-
|
|
246
|
+
def get_env() -> CNotebookEnvInfo:
|
|
247
|
+
"""Get environment information for CNotebook.
|
|
56
248
|
|
|
249
|
+
Returns a singleton instance containing information about available
|
|
250
|
+
backends and environments. The environment is detected once at module
|
|
251
|
+
load time and the same object is returned on subsequent calls.
|
|
57
252
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
253
|
+
:returns: CNotebookEnvInfo instance with read-only properties.
|
|
254
|
+
|
|
255
|
+
Example::
|
|
256
|
+
|
|
257
|
+
env = cnotebook.get_env()
|
|
258
|
+
if env.pandas_available:
|
|
259
|
+
print(f"Pandas {env.pandas_version} is available")
|
|
61
260
|
"""
|
|
62
|
-
|
|
63
|
-
DEBUG_FORMAT = "%(levelname)s: %(message)s"
|
|
261
|
+
return _env_info
|
|
64
262
|
|
|
65
|
-
def __init__(self):
|
|
66
|
-
super().__init__(fmt=self.NORMAL_FORMAT, datefmt=None, style='%')
|
|
67
263
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
:param record: Record to format
|
|
72
|
-
:return: Formatted record
|
|
73
|
-
"""
|
|
74
|
-
if record.levelno == logging.DEBUG:
|
|
75
|
-
self._style._fmt = self.DEBUG_FORMAT
|
|
76
|
-
else:
|
|
77
|
-
self._style._fmt = self.NORMAL_FORMAT
|
|
264
|
+
########################################################################################################################
|
|
265
|
+
# Register Formatters Based on Availability
|
|
266
|
+
########################################################################################################################
|
|
78
267
|
|
|
79
|
-
|
|
80
|
-
|
|
268
|
+
# Import and register pandas formatters if available
|
|
269
|
+
if _env_info.pandas_available:
|
|
270
|
+
try:
|
|
271
|
+
from .pandas_ext import render_dataframe, register_pandas_formatters
|
|
81
272
|
|
|
82
|
-
|
|
273
|
+
if _env_info.ipython_available:
|
|
274
|
+
from .ipython_ext import register_ipython_formatters
|
|
275
|
+
register_ipython_formatters()
|
|
276
|
+
register_pandas_formatters()
|
|
277
|
+
log.debug("[cnotebook] Registered Pandas formatters for iPython")
|
|
278
|
+
except Exception as e:
|
|
279
|
+
log.warning(f"[cnotebook] Failed to import/register Pandas extension: {e}")
|
|
83
280
|
|
|
281
|
+
# Import and register polars formatters if available
|
|
282
|
+
if _env_info.polars_available:
|
|
283
|
+
try:
|
|
284
|
+
from .polars_ext import render_polars_dataframe, register_polars_formatters
|
|
84
285
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
286
|
+
if _env_info.ipython_available:
|
|
287
|
+
register_polars_formatters()
|
|
288
|
+
log.debug("[cnotebook] Registered Polars formatters for iPython")
|
|
289
|
+
except Exception as e:
|
|
290
|
+
log.warning(f"[cnotebook] Failed to import/register Polars extension: {e}")
|
|
88
291
|
|
|
89
|
-
|
|
90
|
-
|
|
292
|
+
# Import marimo extension if available
|
|
293
|
+
if _env_info.marimo_available:
|
|
294
|
+
try:
|
|
295
|
+
from . import marimo_ext
|
|
296
|
+
log.debug("[cnotebook] Imported Marimo extension")
|
|
297
|
+
except Exception as e:
|
|
298
|
+
log.warning(f"[cnotebook] Failed to import Marimo extension: {e}")
|
|
91
299
|
|
|
92
|
-
|
|
93
|
-
|
|
300
|
+
# Export molgrid at top level if available
|
|
301
|
+
if _env_info.molgrid_available:
|
|
302
|
+
from .grid import molgrid, MolGrid
|
|
94
303
|
|
|
95
|
-
log.setLevel(logging.INFO)
|
|
96
304
|
|
|
305
|
+
########################################################################################################################
|
|
306
|
+
# Unified Display Function
|
|
307
|
+
########################################################################################################################
|
|
97
308
|
|
|
98
|
-
def
|
|
309
|
+
def display(obj, ctx: CNotebookContext | None = None):
|
|
310
|
+
"""Display an OpenEye molecule, display object, or DataFrame in the current notebook environment.
|
|
311
|
+
|
|
312
|
+
This function provides a unified way to display chemistry objects in both Jupyter
|
|
313
|
+
and Marimo notebooks. It automatically detects the environment and uses the
|
|
314
|
+
appropriate display mechanism.
|
|
315
|
+
|
|
316
|
+
:param obj: Object to display. Can be:
|
|
317
|
+
- ``oechem.OEMolBase`` - OpenEye molecule
|
|
318
|
+
- ``oedepict.OE2DMolDisplay`` - OpenEye display object
|
|
319
|
+
- ``pandas.DataFrame`` - Pandas DataFrame (if pandas available)
|
|
320
|
+
- ``polars.DataFrame`` - Polars DataFrame (if polars available)
|
|
321
|
+
:param ctx: Optional rendering context. Only applied to molecules and display
|
|
322
|
+
objects, not DataFrames. If None, uses the global context.
|
|
323
|
+
:returns: A displayable object appropriate for the current environment.
|
|
324
|
+
:raises TypeError: If the object type is not supported.
|
|
325
|
+
|
|
326
|
+
Example::
|
|
327
|
+
|
|
328
|
+
import cnotebook
|
|
329
|
+
from openeye import oechem
|
|
330
|
+
|
|
331
|
+
mol = oechem.OEGraphMol()
|
|
332
|
+
oechem.OESmilesToMol(mol, "c1ccccc1")
|
|
333
|
+
|
|
334
|
+
# Display with default context
|
|
335
|
+
cnotebook.display(mol)
|
|
336
|
+
|
|
337
|
+
# Display with custom context
|
|
338
|
+
ctx = cnotebook.cnotebook_context.get().copy()
|
|
339
|
+
ctx.width = 300
|
|
340
|
+
ctx.height = 300
|
|
341
|
+
cnotebook.display(mol, ctx=ctx)
|
|
99
342
|
"""
|
|
100
|
-
|
|
343
|
+
from .render import oemol_to_html, oedisp_to_html
|
|
344
|
+
|
|
345
|
+
# Get environment info
|
|
346
|
+
env = get_env()
|
|
347
|
+
|
|
348
|
+
# Determine the context to use
|
|
349
|
+
if ctx is None:
|
|
350
|
+
render_ctx = cnotebook_context.get()
|
|
351
|
+
else:
|
|
352
|
+
render_ctx = ctx
|
|
353
|
+
|
|
354
|
+
# Handle OpenEye molecules
|
|
355
|
+
if isinstance(obj, oechem.OEMolBase):
|
|
356
|
+
html = oemol_to_html(obj, ctx=render_ctx)
|
|
357
|
+
return _display_html(html, env)
|
|
358
|
+
|
|
359
|
+
# Handle OpenEye display objects
|
|
360
|
+
if isinstance(obj, oedepict.OE2DMolDisplay):
|
|
361
|
+
html = oedisp_to_html(obj, ctx=render_ctx)
|
|
362
|
+
return _display_html(html, env)
|
|
363
|
+
|
|
364
|
+
# Handle Pandas DataFrame (if available)
|
|
365
|
+
if env.pandas_available:
|
|
366
|
+
import pandas as pd
|
|
367
|
+
if isinstance(obj, pd.DataFrame):
|
|
368
|
+
# noinspection PyTypeChecker
|
|
369
|
+
html = render_dataframe(obj, ctx=render_ctx)
|
|
370
|
+
return _display_html(html, env)
|
|
371
|
+
|
|
372
|
+
# Handle Polars DataFrame (if available)
|
|
373
|
+
if env.polars_available:
|
|
374
|
+
import polars as pl
|
|
375
|
+
if isinstance(obj, pl.DataFrame):
|
|
376
|
+
html = render_polars_dataframe(obj, ctx=render_ctx)
|
|
377
|
+
return _display_html(html, env)
|
|
378
|
+
|
|
379
|
+
raise TypeError(f"Cannot display object of type {type(obj).__name__}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _display_html(html: str, env: CNotebookEnvInfo):
|
|
383
|
+
"""Display HTML content in the appropriate notebook environment.
|
|
384
|
+
|
|
385
|
+
:param html: HTML string to display.
|
|
386
|
+
:param env: Environment info.
|
|
387
|
+
:returns: Displayable object for the current environment.
|
|
101
388
|
"""
|
|
102
|
-
|
|
389
|
+
# Marimo environment
|
|
390
|
+
if env.is_marimo_notebook:
|
|
391
|
+
import marimo as mo
|
|
392
|
+
return mo.Html(html)
|
|
393
|
+
|
|
394
|
+
# Jupyter/IPython environment
|
|
395
|
+
if env.ipython_available:
|
|
396
|
+
from IPython.display import HTML, display as ipy_display
|
|
397
|
+
return ipy_display(HTML(html))
|
|
398
|
+
|
|
399
|
+
# Fallback: just return the HTML string
|
|
400
|
+
return html
|