diff --git a/examples/reference/panes/Markdown.ipynb b/examples/reference/panes/Markdown.ipynb index ef4f0055e1..1f1a311215 100644 --- a/examples/reference/panes/Markdown.ipynb +++ b/examples/reference/panes/Markdown.ipynb @@ -23,10 +23,11 @@ "\n", "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", "\n", - "* **`dedent`** (bool): Whether to dedent common whitespace across all lines.\n", + "* **`dedent`** (bool, `default=True`): Whether to dedent common whitespace across all lines.\n", "* **`disable_math`** (boolean, `default=False`): Whether to disable MathJax math rendering for strings escaped with `$$` delimiters.\n", "* **`enable_streaming`** (boolean, `default=False`): Whether to enable streaming of text snippets. This will diff the `object` when it is updated and only send the trailing chunk that was added.\n", "* **`extensions`** (list): A list of [Python-Markdown extensions](https://python-markdown.github.io/extensions/) to use (does not apply for 'markdown-it' and 'myst' renderers).\n", + "* **`hard_line_break`** (bool, `default=False`): Whether simple new lines are rendered as hard line breaks. False by default to conform with the original Markdown spec. Not supported by the `'myst'` renderer.\n", "* **`object`** (str or object): A string containing Markdown, or an object with a ``_repr_markdown_`` method.\n", "* **`plugins`** (function): A list of additional markdown-it-py plugins to apply.\n", "* **`renderer`** (literal: `'markdown-it'`, `'markdown'`, `'myst'`): Markdown renderer implementation.\n", @@ -349,6 +350,59 @@ "source": [ "pn.pane.Markdown(r\"$$\\frac{1}{n}$$\")" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## New Lines\n", + "\n", + "Markdown renderers typically do not interpret new lines as hard line breaks. According to the Markdown standard, a hard line break requires the line to end with either *two spaces* or a *backslash*. Otherwise, it is treated as a soft line break. By default, Panel adheres to this standard, respecting the behavior where simple new lines are not displayed as hard line breaks." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pn.pane.Markdown(\"\"\"\n", + "Markdown displayed\n", + "on two lines.\n", + "\"\"\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Rendering simple new lines as hard line breaks can be enabled for all renderers but `'myst'` by setting `hard_line_break` to `True`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pn.pane.Markdown(\"\"\"\n", + "Markdown displayed\n", + "on one line.\n", + "\n", + "Markdown displayed on multiple lines \n", + "as each line ends with two spaces, \n", + "despite hard_line_break=True\n", + "\"\"\", hard_line_break=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + ":::{hint}\n", + "`hard_line_break=True` is appropriate for displaying Markdown text generated by LLMs.\n", + ":::\n" + ] } ], "metadata": { diff --git a/panel/chat/message.py b/panel/chat/message.py index 395a70c81a..f26accef53 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -515,6 +515,8 @@ def _create_panel(self, value, old=None): self._set_params(old, enable_streaming=True, object=value) return old object_panel = _panel(value) + if isinstance(object_panel, Markdown) and not object_panel.hard_line_break: + object_panel.hard_line_break = True self._set_params(object_panel) if type(old) is type(object_panel) and self._internal: diff --git a/panel/pane/markup.py b/panel/pane/markup.py index b14b930c2d..18a3bd0ef2 100644 --- a/panel/pane/markup.py +++ b/panel/pane/markup.py @@ -357,6 +357,11 @@ class Markdown(HTMLBasePane): Markdown extension to apply when transforming markup. Does not apply if renderer is set to 'markdown-it' or 'myst'.""") + hard_line_break = param.Boolean(default=False, doc=""" + Whether simple new lines are rendered as hard line breaks. False by + default to conform with the original Markdown spec. Not supported by + the 'myst' renderer.""") + plugins = param.List(default=[], nested_refs=True, doc=""" Additional markdown-it-py plugins to use.""") @@ -371,6 +376,7 @@ class Markdown(HTMLBasePane): priority: ClassVar[float | bool | None] = None _rename: ClassVar[Mapping[str, str | None]] = { + 'hard_line_break': None, 'dedent': None, 'disable_math': None, 'extensions': None, 'plugins': None, 'renderer': None, 'renderer_options': None } @@ -398,7 +404,7 @@ def applies(cls, obj: Any) -> float | bool | None: @classmethod @functools.cache - def _get_parser(cls, renderer, plugins, **renderer_options): + def _get_parser(cls, renderer, plugins, hard_line_break, **renderer_options): if renderer == 'markdown': return None from markdown_it import MarkdownIt @@ -416,7 +422,7 @@ def hilite(token, langname, attrs): return token if renderer == 'markdown-it': - if "breaks" not in renderer_options: + if hard_line_break and "breaks" not in renderer_options: renderer_options["breaks"] = True parser = MarkdownIt( @@ -458,15 +464,16 @@ def _transform_object(self, obj: Any) -> dict[str, Any]: obj = textwrap.dedent(obj) if self.renderer == 'markdown': + extensions = self.extensions + ['nl2br'] if self.hard_line_break else self.extensions html = markdown.markdown( obj, - extensions=self.extensions, + extensions=extensions, output_format='xhtml', **self.renderer_options ) else: parser = self._get_parser( - self.renderer, tuple(self.plugins), **self.renderer_options + self.renderer, tuple(self.plugins), self.hard_line_break, **self.renderer_options ) try: html = parser.render(obj) diff --git a/panel/tests/pane/test_markup.py b/panel/tests/pane/test_markup.py index 0330ab8460..0e52cccb7c 100644 --- a/panel/tests/pane/test_markup.py +++ b/panel/tests/pane/test_markup.py @@ -1,4 +1,5 @@ import base64 +import html import json import sys @@ -78,33 +79,73 @@ def test_markdown_pane_dedent(document, comm): pane.dedent = False assert model.text.startswith('<pre><code>ABC') -def test_markdown_pane_newline(document, comm): - # Newlines should be separated by a br - pane = Markdown( - "Hello\nWorld\nI'm here!", - renderer="markdown-it", - ) +@pytest.mark.parametrize('renderer', ('markdown-it', 'markdown')) +def test_markdown_pane_hard_line_break_default(document, comm, renderer): + assert Markdown.hard_line_break is False + txt = "Hello\nWorld\nI am here" + pane = Markdown(txt, renderer=renderer) model = pane.get_root(document, comm=comm) assert pane._models[model.ref['id']][0] is model - # <p>Hello<br>World<br>I'm here!</p> - assert model.text == "<p>Hello<br />\nWorld<br />\nI'm here!</p>\n" + # No <br />, single <p> + assert html.unescape(model.text).rstrip() == f"<p>{txt}</p>" - # Two newlines should be separated by a div - pane = Markdown("Hello\n\nWorld") +@pytest.mark.parametrize('renderer', ('markdown-it', 'markdown')) +def test_markdown_pane_hard_line_break_enabled(document, comm, renderer): + assert Markdown.hard_line_break is False + pane = Markdown("Hello\nWorld\nI am here", renderer=renderer, hard_line_break=True) + model = pane.get_root(document, comm=comm) + assert pane._models[model.ref['id']][0] is model + # Two <br />, single <p> + assert html.unescape(model.text).rstrip() == "<p>Hello<br />\nWorld<br />\nI am here</p>" + +@pytest.mark.parametrize('hard_line_break', (False, True)) +def test_markdown_pane_hard_line_break_myst(document, comm, hard_line_break): + pytest.importorskip("myst_parser") + # hard_line_break not supported + assert Markdown.hard_line_break is False + txt = "Hello\nWorld\nI am here" + pane = Markdown(txt, renderer='myst', hard_line_break=hard_line_break) + model = pane.get_root(document, comm=comm) + assert pane._models[model.ref['id']][0] is model + # No <br />, single <p> + assert html.unescape(model.text).rstrip() == f"<p>{txt}</p>" + +@pytest.mark.parametrize('renderer', ('markdown-it', 'markdown', 'myst')) +@pytest.mark.parametrize('hard_line_break', (False, True)) +def test_markdown_pane_hard_line_break_default_two_spaces(document, comm, renderer, hard_line_break): + if renderer == 'myst': + pytest.importorskip("myst_parser") + # Same output, whether hard_line_break is True or False + assert Markdown.hard_line_break is False + # Note the two empty spaces at the end of each line. + pane = Markdown("Hello \nWorld \nI am here", renderer=renderer, hard_line_break=hard_line_break) + model = pane.get_root(document, comm=comm) + assert pane._models[model.ref['id']][0] is model + # Two <br />, single <p> + assert html.unescape(model.text).rstrip() == "<p>Hello<br />\nWorld<br />\nI am here</p>" + +@pytest.mark.parametrize('renderer', ('markdown-it', 'markdown', 'myst')) +def test_markdown_pane_two_new_lines(document, comm, renderer): + if renderer == 'myst': + pytest.importorskip("myst_parser") + assert Markdown.hard_line_break is False + pane = Markdown("Hello\n\nWorld", renderer=renderer) model = pane.get_root(document, comm=comm) assert pane._models[model.ref['id']][0] is model - # <p>Hello</p><p>World</p> - assert model.text == "<p>Hello</p>\n<p>World</p>\n" + # Two <p> elements + assert html.unescape(model.text).rstrip() == "<p>Hello</p>\n<p>World</p>" - # Disable newlines +def test_markdown_pane_markdown_it_render_options_breaks(document, comm): + assert Markdown.hard_line_break is False pane = Markdown( - "Hello\nWorld\nI'm here!", + "Hello\nWorld\nI am here", renderer="markdown-it", - renderer_options={"breaks": False}, + renderer_options={"breaks": True}, ) model = pane.get_root(document, comm=comm) assert pane._models[model.ref['id']][0] is model - assert model.text == "<p>Hello\nWorld\nI'm here!</p>\n" + # Two <br />, single <p> + assert html.unescape(model.text).rstrip() == "<p>Hello<br />\nWorld<br />\nI am here</p>" def test_markdown_pane_markdown_it_renderer(document, comm): pane = Markdown(""" diff --git a/panel/tests/ui/pane/test_markup.py b/panel/tests/ui/pane/test_markup.py index 71399b79d0..e47633d39f 100644 --- a/panel/tests/ui/pane/test_markup.py +++ b/panel/tests/ui/pane/test_markup.py @@ -121,7 +121,7 @@ def test_anchor_scroll(page): md = '' for tag in ['tag1', 'tag2', 'tag3']: md += f'# {tag}\n\n' - md += f'{tag} content\n' * 50 + md += f'{tag} content \n' * 50 content = Markdown(md) link = Markdown('<a id="link1" href="#tag1">Link1</a><a id="link3" href="#tag3">Link</a>') @@ -145,7 +145,7 @@ def test_anchor_scroll_on_init(page): md = '' for tag in ['tag1', 'tag2', 'tag3']: md += f'# {tag}\n\n' - md += f'{tag} content\n' * 50 + md += f'{tag} content \n' * 50 content = Markdown(md) diff --git a/panel/widgets/base.py b/panel/widgets/base.py index 78fcebc790..22a4d281ed 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -134,7 +134,7 @@ def _process_param_change(self, params: dict[str, Any]) -> dict[str, Any]: renderer_options = params.pop("renderer_options", {}) if isinstance(description, str): from ..pane.markup import Markdown - parser = Markdown._get_parser('markdown-it', (), **renderer_options) + parser = Markdown._get_parser('markdown-it', (), Markdown.hard_line_break, **renderer_options) html = parser.render(description) params['description'] = Tooltip( content=HTML(html), position='right',