diff --git a/README.md b/README.md index a4e18d7..0f31b09 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,11 @@ Files without a language code (`page.md`) are still served, as a last resort, fo The extensions `toc`, `meta`, `tables`, and a built-in Bootstrap extension are always loaded. +An image that carries a title and stands alone in its paragraph is rendered as a +captioned `
`: the title becomes the caption, and the alt text keeps describing +the image for those who cannot see it. An image without a title, or one sitting inside a +sentence, is rendered as a plain ``. + ## Development ### Requirements diff --git a/flask_wiki/markdown_ext.py b/flask_wiki/markdown_ext.py index 7967b57..92068f9 100644 --- a/flask_wiki/markdown_ext.py +++ b/flask_wiki/markdown_ext.py @@ -3,20 +3,23 @@ """Python-Markdown extensions.""" +from xml.etree.ElementTree import Element, SubElement + from markdown import Extension from markdown.treeprocessors import Treeprocessor class BootstrapExtension(Extension): - """Python-Markdown extension that adds Bootstrap CSS classes to HTML elements.""" + """Python-Markdown extension that adapts the rendered HTML to the wiki templates.""" def extendMarkdown(self, md): # noqa: N802 - """Register the Bootstrap tree processor with the Markdown instance.""" + """Register the wiki tree processors with the Markdown instance.""" md.registerExtension(self) self.processor = BootstrapTreeprocessor() self.processor.md = md self.processor.config = self.getConfigs() md.treeprocessors.register(self.processor, "bootstrap", 1) + md.treeprocessors.register(FigureTreeprocessor(md), "figure", 10) class BootstrapTreeprocessor(Treeprocessor): @@ -35,3 +38,58 @@ def run(self, node): child.set("class", "table table-striped") return node + + +class FigureTreeprocessor(Treeprocessor): + """Tree processor that renders a captioned image as a figure.""" + + def run(self, node): + """Replace every paragraph made of a single titled image with a figure. + + :param node: the root element tree node + :returns: the modified element tree node + """ + for parent in node.iter(): + for index, child in enumerate(parent): + if self.is_lone_titled_image(child): + parent[index] = self.figure(child) + + return node + + @staticmethod + def is_lone_titled_image(element): + """Return True if the element is a paragraph holding nothing but a titled image. + + A figure is a block element, so an image only becomes one when it stands alone + in its paragraph; and its caption is the image title, so an image without one + is left as it is. + + :param element: the element tree node to check + :rtype: bool + """ + return bool( + element.tag == "p" + and not (element.text or "").strip() + and len(element) == 1 + and element[0].tag == "img" + and not (element[0].tail or "").strip() + and element[0].get("title") + ) + + @staticmethod + def figure(paragraph): + """Return the figure replacing a paragraph made of a single titled image. + + The title becomes the caption and is removed from the image, which would + otherwise repeat it as a tooltip. The alt text is left untouched, as it + describes the image for those who cannot see it. + + :param paragraph: the paragraph element tree node + :returns: a figure element wrapping the image and its caption + """ + image = paragraph[0] + figure = Element("figure") + figure.tail = paragraph.tail + figure.append(image) + SubElement(figure, "figcaption").text = image.attrib.pop("title") + return figure diff --git a/flask_wiki/static/css/wiki.css b/flask_wiki/static/css/wiki.css index 8b59b54..bde8bb5 100644 --- a/flask_wiki/static/css/wiki.css +++ b/flask_wiki/static/css/wiki.css @@ -27,6 +27,26 @@ h1 { max-width: 55rem; } +/* Figures, as rendered for an image carrying a title. */ +.content figure { + max-width: 85%; + margin: 0 auto; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 5px; +} + +.content figure img { + border-radius: 5px; +} + +.content figcaption { + padding-top: 0.5rem; + text-align: center; + font-size: 90%; + color: #6c757d; +} + .wiki-page .toc ul { padding-left: 1rem; } @@ -39,10 +59,11 @@ h1 { height: 400px; } -.toast { - position: absolute; +.wiki-toasts { + position: fixed; top: 50px; left: 15px; + z-index: 1090; } .wiki-files, diff --git a/flask_wiki/templates/wiki/base.html b/flask_wiki/templates/wiki/base.html index 9399f96..4cbd8bd 100644 --- a/flask_wiki/templates/wiki/base.html +++ b/flask_wiki/templates/wiki/base.html @@ -31,20 +31,22 @@ {{ render_messages(dismissible=True, dismiss_animate=True) }} {% block navigation %} {%- include "wiki/navigation.html" %} {% endblock %} {% block content %} -
-
- Markdown +
+
+
+ Markdown +
+
+ {{ _('Link copied to your clipboard') }} +
-
- {{ _('Link copied to your clipboard') }} -
-
-
-
- Markdown -
-
- {{ _('Failed to copy link to your clipboard') }} +
+
+ Markdown +
+
+ {{ _('Failed to copy link to your clipboard') }} +
{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index e30f4fe..4f3ed2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ dependencies = [ "beautifulsoup4", "click", "whoosh-reloaded>=2.7.5", - "markdown-captions>=2.1.2", "poethepoet", ] diff --git a/tests/test_api.py b/tests/test_api.py index b99751b..1e86218 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -58,6 +58,36 @@ def test_processor_table_bootstrap(app): assert "table-striped" in html +def test_processor_figure(app): + """Test that a titled image standing alone in its paragraph becomes a figure.""" + with app.test_request_context(): + text = 'title: T\n\n![Alt text](image.png "A caption")' + processor = Processor(text) + html, _body, _meta, _toc = processor.process() + + assert "
" in html + assert "
A caption
" in html + # the figure is a block of its own, not nested in a paragraph + assert "

" not in html + # the alt text keeps describing the image, the title is consumed + assert 'alt="Alt text"' in html + assert "title=" not in html + + +def test_processor_image_left_alone(app): + """Test that an image is only made a figure when it can be a captioned block.""" + with app.test_request_context(): + # without a title there is nothing to caption the image with + html, _body, _meta, _toc = Processor("title: T\n\n![Alt text](image.png)").process() + assert "

" not in html + + # an image inside a sentence stays inline, a figure being a block + text = 'title: T\n\nSee ![Alt text](image.png "A caption") here.' + html, _body, _meta, _toc = Processor(text).process() + assert "
" not in html + assert 'title="A caption"' in html + + def test_page_load_and_render(app): """Test loading and rendering a page from disk.""" with app.test_request_context(): diff --git a/tests/test_views.py b/tests/test_views.py index f66fd55..9d5b343 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -5,7 +5,9 @@ import io import os +import re from html.parser import HTMLParser +from pathlib import Path from tests.conftest import TINY_PNG @@ -267,6 +269,24 @@ def test_pages_do_not_reference_external_assets(client): assert not asset.startswith(("http://", "https://", "//")), f"{url}: {asset}" +def test_toasts_are_placed_by_their_own_wrapper(client, app): + """Test that the toasts are positioned by a wiki class instead of `.toast`. + + Styling the Bootstrap component itself leaks into the host application, which + places the wiki toasts with its own rules. + """ + assert 'class="wiki-toasts"' in client.get("/help/home/").data.decode() + + stylesheet = Path(app.blueprints["wiki"].static_folder, "css", "wiki.css") + css = re.sub(r"/\*.*?\*/", "", stylesheet.read_text(), flags=re.DOTALL) + selectors = { + selector.strip() for rule in css.split("}") if "{" in rule for selector in rule.rsplit("{", 1)[0].split(",") + } + assert ".wiki-toasts" in selectors + for selector in selectors: + assert "toast" not in re.findall(r"\.([-\w]+)", selector), selector + + def test_icons_come_from_the_bootstrap_sprite(client): """Test that template icons are inline SVG from the Bootstrap Icons sprite.""" html = client.get("/help/home/").data.decode() diff --git a/uv.lock b/uv.lock index b07ec7e..2f3b5a4 100644 --- a/uv.lock +++ b/uv.lock @@ -267,7 +267,6 @@ dependencies = [ { name = "flask-babel" }, { name = "flask-wtf" }, { name = "markdown" }, - { name = "markdown-captions" }, { name = "poethepoet" }, { name = "werkzeug" }, { name = "whoosh-reloaded" }, @@ -292,7 +291,6 @@ requires-dist = [ { name = "flask-babel", specifier = ">=3.0.0" }, { name = "flask-wtf" }, { name = "markdown" }, - { name = "markdown-captions", specifier = ">=2.1.2" }, { name = "poethepoet" }, { name = "werkzeug", specifier = ">=0.15" }, { name = "whoosh-reloaded", specifier = ">=2.7.5" }, @@ -426,15 +424,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] -[[package]] -name = "markdown-captions" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/1d/15ef64ed904c85d0520d632ec6844b7da886a45b0cb7970644be493181d0/markdown-captions-2.1.2.tar.gz", hash = "sha256:1c3ec0b674dd41a4c5f125724188c32a17bd46fd527db29f0c55a001e2d7abaf", size = 3031, upload-time = "2022-07-19T22:29:28.959Z" } - [[package]] name = "markdown-it-py" version = "4.2.0"