diff --git a/.gitignore b/.gitignore index 01a3f9b..807c3ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Python +# Python __pycache__/ *.py[cod] *$py.class @@ -8,6 +8,11 @@ __pycache__/ dist/ build/ +# Node / Companion dependencies +web_companion/node_modules/ +web_companion/dist/ +*.tsbuildinfo + # Virtual environments venv/ .venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 945fc58..b92a040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). ## [Unreleased] +### Added +- **Image Display Improvements (Bessere Bildanzeige):** + - High-resolution interactive `ImagePreviewDialog` with Zoom (+/- / Fit / 1:1), Clipboard Copying, and image metadata. + - Enhanced HTML rendering wrapping markdown images into `
` elements with clickable `` links and `
` text from `title`/`alt`. + - Responsive image styling with rounded borders, dark/light theme elevation shadows, and adaptive scaling. + - Enhanced `_insert_image` dialog supporting both local file browsing (`QFileDialog`) with relative path resolution and URL entry. + - Added 7 new i18n keys (`image_preview`, `zoom_in`, `zoom_out`, `zoom_reset`, `zoom_fit`, `copy_image`, `select_image_file`) across all 6 supported languages (`de`, `en`, `es`, `zh`, `ja`, `ru`). + +### Changed +- **Discoverability, SEO & Maintenance (Sichtbarkeit & Pflege):** + - Updated `llms.txt` `Last-checked` timestamp to 2026-08-03 and expanded 6-language UI context. + - Added Ökosystem (`doc-bricks`) & Umbrella (`open-bricks`) Shields.io badges and GFM `llms.txt` discoverability callout in `README.md` and `README_DE.md`. + - Updated version badges to `0.3.3` across documentation. + - Verified pytest test suite (91/91 passed). [G 2026-08-03] + ## [0.3.3] - 2026-07-25 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8169a29..52e9e18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,3 +58,6 @@ python -m py_compile main.py python main.py --self-test python main.py ``` + + + \ No newline at end of file diff --git a/README.md b/README.md index f0224f6..441c0b8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -CleanMarkdown Banner +CleanMarkdown Banner # CleanMarkdown @@ -6,10 +6,15 @@ Fast local Markdown viewer and editor with a clean reading mode, raw Markdown editing, PDF export, and a 6-language UI (`de`, `en`, `es`, `zh`, `ja`, `ru`). +[![Ökosystem: doc-bricks](https://img.shields.io/badge/%C3%96kosystem-doc--bricks-blue.svg)](https://github.com/doc-bricks) +[![Umbrella: open-bricks](https://img.shields.io/badge/Umbrella-open--bricks-purple.svg)](https://github.com/open-bricks) [![License: MIT](https://img.shields.io/badge/License-MIT-teal.svg)](LICENSE) [![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-blue.svg)](https://python.org) [![Platform: Windows](https://img.shields.io/badge/Platform-Windows-lightgrey.svg)](https://github.com/doc-bricks/CleanMarkdown) -[![Version](https://img.shields.io/badge/Version-0.3.2-teal.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-0.3.3-teal.svg)](CHANGELOG.md) + +> [!NOTE] +> Machine-readable project overview and AI context available in [`llms.txt`](llms.txt). ## Features @@ -96,7 +101,7 @@ The raw editor uses a restrained four-group color system: ## Project Status -Current version: `0.3.2` +Current version: `0.3.3` CleanMarkdown is already usable as a small public MVP. The current focus is practical polish around real-world rendering and PDF export, not feature bloat. @@ -107,13 +112,10 @@ python -m pip install -r requirements.txt pytest python -m py_compile main.py python -m pytest -q python main.py --self-test -cd web_companion -npm ci -npm run build python main.py ``` -The self-test covers file opening, save/export flows, task lists, math markup, scroll sync, relative asset resolution, and real Markdown roundtrips across public repository docs. Pytest covers renderer, formatting and file/session edge cases. The web companion build validates the PWA TypeScript bundle. +The self-test covers file opening, save/export flows, task lists, math markup, scroll sync, relative asset resolution, and real Markdown roundtrips across public repository docs. Pytest covers renderer, formatting and file/session edge cases. For the mobile line in `flutter_port/`, the current verified scope is local `.md`/`.markdown` open, live preview, raw editing, and local save. The editor intentionally disables smart quotes, smart dashes, suggestions, and auto-capitalization so Markdown syntax is not rewritten by the mobile keyboard. diff --git a/README_DE.md b/README_DE.md index 860ed94..2cad2d6 100644 --- a/README_DE.md +++ b/README_DE.md @@ -1,4 +1,4 @@ -CleanMarkdown Banner +CleanMarkdown Banner # CleanMarkdown @@ -6,10 +6,15 @@ Deutsch · **[English](README.md)** Schneller lokaler Markdown-Viewer und -Editor mit cleanem Lesemodus, Raw-Markdown-Bearbeitung, PDF-Export und 6-Sprachen-Oberfläche (`de`, `en`, `es`, `zh`, `ja`, `ru`). +[![Ökosystem: doc-bricks](https://img.shields.io/badge/%C3%96kosystem-doc--bricks-blue.svg)](https://github.com/doc-bricks) +[![Umbrella: open-bricks](https://img.shields.io/badge/Umbrella-open--bricks-purple.svg)](https://github.com/open-bricks) [![Lizenz: MIT](https://img.shields.io/badge/Lizenz-MIT-teal.svg)](LICENSE) [![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-blue.svg)](https://python.org) [![Plattform: Windows](https://img.shields.io/badge/Plattform-Windows-lightgrey.svg)](https://github.com/doc-bricks/CleanMarkdown) -[![Version](https://img.shields.io/badge/Version-0.3.2-teal.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-0.3.3-teal.svg)](CHANGELOG.md) + +> [!NOTE] +> Maschinenlesbare Projektübersicht und KI-Kontext verfügbar in [`llms.txt`](llms.txt). ## Funktionen @@ -95,7 +100,7 @@ Der Raw-Editor nutzt eine reduzierte Vierer-Gruppierung: ## Projektstatus -Aktuelle Version: `0.3.2` +Aktuelle Version: `0.3.3` CleanMarkdown ist bereits als kleines öffentliches MVP nutzbar. Der aktuelle Schwerpunkt liegt auf praktischer Qualität bei realem Rendering und PDF-Export, nicht auf unnötigem Feature-Ausbau. @@ -106,13 +111,10 @@ python -m pip install -r requirements.txt pytest python -m py_compile main.py python -m pytest -q python main.py --self-test -cd web_companion -npm ci -npm run build python main.py ``` -Der Selbsttest prüft Öffnen, Speichern/Export, Task-Listen, Mathe-Markup, Scroll-Sync, relative Asset-Auflösung und echte Markdown-Roundtrips über öffentliche Repo-Dokumente. Pytest deckt Renderer-, Formatierungs- und Datei-/Session-Randfälle ab. Der Web-Companion-Build validiert das PWA-TypeScript-Bundle. +Der Selbsttest prüft Öffnen, Speichern/Export, Task-Listen, Mathe-Markup, Scroll-Sync, relative Asset-Auflösung und echte Markdown-Roundtrips über öffentliche Repo-Dokumente. Pytest deckt Renderer-, Formatierungs- und Datei-/Session-Randfälle ab. ## Lizenz diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..6804869 Binary files /dev/null and b/assets/banner.png differ diff --git a/llms.txt b/llms.txt index 53bdb84..4bc4612 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # CleanMarkdown -> Fast local Markdown viewer and editor with a clean reading mode, raw Markdown editing, PDF export, and DE/EN UI. Runs offline without a cloud account. +> Fast local Markdown viewer and editor with a clean reading mode, raw Markdown editing, PDF export, and 6-language UI (`de`, `en`, `es`, `zh`, `ja`, `ru`). Runs offline without a cloud account. ## Canonical links @@ -11,8 +11,9 @@ - Two-tab interface: clean rendered reading view and raw Markdown editor. - PDF export with timestamp-based filenames. - Session export/import via `cleanmarkdown-session-v1.json` for local handoff to the web companion PWA. +- Interactive high-resolution image preview dialog with Zoom (+/- / Fit / 1:1), Clipboard Copying, and image metadata. - Lightweight math preview for `$...$`, `$$...$$`, `\(...\)`, and `\[...\]`. -- German and English UI with light and dark theme. +- 6-language UI (`de`, `en`, `es`, `zh`, `ja`, `ru`) with light and dark theme. - Autosave, relative image resolution, and four-group syntax highlighting. - Web companion PWA for browser-based access to the same document. - Flutter mobile port (Android/iOS, `flutter_port/`) in development. @@ -24,16 +25,14 @@ python -m pip install -r requirements.txt python -m pytest -q python main.py --self-test -cd web_companion && npm ci && npm test ``` ## Important files - `main.py` — PySide6 desktop application entry point. -- `translator.py` / `locales/translations.json` — DE/EN i18n system. -- `web_companion/` — Vite/TypeScript PWA companion. +- `translator.py` / `locales/translations.json` — 6-language i18n system (`de`, `en`, `es`, `zh`, `ja`, `ru`). - `flutter_port/` — Flutter Android/iOS mobile port. -- `tests/` — pytest suite (51 tests: rendering, session, file/export edge cases). +- `tests/` — pytest suite (91 tests: rendering, session, file/export edge cases, image preview). - `EXPORTFORMAT.md` — session and bundle format contract. ## Audience @@ -52,10 +51,11 @@ Markdown viewer PDF export offline clean Markdown editor PySide6 local Markdown PDF converter distraction-free Markdown reader dark mode -German English Markdown editor desktop app +multilingual Markdown editor desktop app Markdown editor no cloud no account standalone Markdown viewer local-first ``` -## Last-checked: 2026-07-25 +## Last-checked: 2026-08-03 + diff --git a/locales/translations.json b/locales/translations.json index 706eee6..f38e7e0 100644 --- a/locales/translations.json +++ b/locales/translations.json @@ -807,5 +807,61 @@ "zh": "仅在编辑器中可用", "ja": "エディタでのみ利用可能", "ru": "Доступно только в редакторе" + }, + "image_preview": { + "de": "Bild-Vorschau", + "en": "Image Preview", + "es": "Vista previa de la imagen", + "zh": "图片预览", + "ja": "画像プレビュー", + "ru": "Просмотр изображения" + }, + "zoom_in": { + "de": "Vergrößern", + "en": "Zoom In", + "es": "Acercar", + "zh": "放大", + "ja": "拡大", + "ru": "Увеличить" + }, + "zoom_out": { + "de": "Verkleinern", + "en": "Zoom Out", + "es": "Alejar", + "zh": "缩小", + "ja": "縮小", + "ru": "Уменьшить" + }, + "zoom_reset": { + "de": "Originalgröße", + "en": "Actual Size", + "es": "Tamaño real", + "zh": "实际大小", + "ja": "原寸大", + "ru": "Реальный размер" + }, + "zoom_fit": { + "de": "An Fenster anpassen", + "en": "Fit to Window", + "es": "Ajustar a la ventana", + "zh": "适应窗口", + "ja": "ウィンドウに合わせる", + "ru": "По размеру окна" + }, + "copy_image": { + "de": "Bild kopieren", + "en": "Copy Image", + "es": "Copiar imagen", + "zh": "复制图片", + "ja": "画像をコピー", + "ru": "Копировать изображение" + }, + "select_image_file": { + "de": "Bilddatei auswählen", + "en": "Select image file", + "es": "Seleccionar archivo de imagen", + "zh": "选择图片文件", + "ja": "画像ファイルを選択", + "ru": "Выбрать файл изображения" } } diff --git a/main.py b/main.py index a018731..4c2eada 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,8 @@ QIcon, QKeySequence, QPageLayout, + QPalette, + QPixmap, QSyntaxHighlighter, QTextCharFormat, QTextCursor, @@ -43,6 +45,7 @@ QMessageBox, QPlainTextEdit, QPushButton, + QScrollArea, QSpinBox, QStatusBar, QStyle, @@ -126,7 +129,9 @@ def configure_application(app: QApplication) -> None: th, td { border: 1px solid #324052; padding: 8px 10px; } th { background: #1a2430; } hr { border: none; border-top: 1px solid #39475c; margin: 1.8em 0; } - img { max-width: 100%; } + img { max-width: 100%; height: auto; display: block; margin: 1.2em auto; border-radius: 8px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); border: 1px solid #2b3648; background-color: rgba(255, 255, 255, 0.03); } + figure { margin: 1.5em 0; text-align: center; } + figcaption { font-size: 0.88em; margin-top: 0.5em; color: #a0b2c6; font-style: italic; text-align: center; } ul.task-list { list-style: none; padding-left: 0.2em; } .task-box { display: inline-block; min-width: 1.5em; color: #9bc0e7; } .math-inline { font-family: "Cambria Math", "STIX Two Math", "Times New Roman", serif; background: rgba(46, 67, 93, 0.55); color: #f6fbff; border: 1px solid #496a8f; border-radius: 6px; padding: 0.08em 0.38em; white-space: pre-wrap; } @@ -179,7 +184,9 @@ def configure_application(app: QApplication) -> None: th, td { border: 1px solid #d3dcea; padding: 8px 10px; } th { background: #edf3fb; } hr { border: none; border-top: 1px solid #d3dcea; margin: 1.8em 0; } - img { max-width: 100%; } + img { max-width: 100%; height: auto; display: block; margin: 1.2em auto; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); border: 1px solid #d4deed; background-color: #ffffff; } + figure { margin: 1.5em 0; text-align: center; } + figcaption { font-size: 0.88em; margin-top: 0.5em; color: #586879; font-style: italic; text-align: center; } ul.task-list { list-style: none; padding-left: 0.2em; } .task-box { display: inline-block; min-width: 1.5em; color: #4d6f95; } .math-inline { font-family: "Cambria Math", "STIX Two Math", "Times New Roman", serif; background: #eef4fb; color: #21476f; border: 1px solid #c7d8eb; border-radius: 6px; padding: 0.08em 0.38em; white-space: pre-wrap; } @@ -355,6 +362,119 @@ def _documents_dir() -> Path: return Path(location) if location else Path.home() / "Documents" +class ImagePreviewDialog(QDialog): + """Interaktiver Dialog zur hochaufloesenden Bild-Vorschau mit Zoom & Kopieren.""" + + def __init__( + self, + image_path_or_url: str, + translator, + base_dir: Path | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.t = translator + self.setWindowTitle(self.t("image_preview")) + self.setMinimumSize(600, 440) + self.resize(880, 660) + self.scale_factor = 1.0 + + self.pixmap = self._load_pixmap(image_path_or_url, base_dir) + + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setScaledContents(True) + + self.scroll_area = QScrollArea() + self.scroll_area.setBackgroundRole(QPalette.ColorRole.Dark) + self.scroll_area.setWidget(self.image_label) + self.scroll_area.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.zoom_in_btn = QPushButton(self.t("zoom_in")) + self.zoom_out_btn = QPushButton(self.t("zoom_out")) + self.zoom_reset_btn = QPushButton(self.t("zoom_reset")) + self.zoom_fit_btn = QPushButton(self.t("zoom_fit")) + self.copy_btn = QPushButton(self.t("copy_image")) + self.close_btn = QPushButton(self.t("cancel")) + + self.zoom_in_btn.clicked.connect(self._zoom_in) + self.zoom_out_btn.clicked.connect(self._zoom_out) + self.zoom_reset_btn.clicked.connect(self._zoom_reset) + self.zoom_fit_btn.clicked.connect(self._zoom_fit) + self.copy_btn.clicked.connect(self._copy_image) + self.close_btn.clicked.connect(self.accept) + + btn_layout = QHBoxLayout() + btn_layout.addWidget(self.zoom_in_btn) + btn_layout.addWidget(self.zoom_out_btn) + btn_layout.addWidget(self.zoom_reset_btn) + btn_layout.addWidget(self.zoom_fit_btn) + btn_layout.addStretch() + btn_layout.addWidget(self.copy_btn) + btn_layout.addWidget(self.close_btn) + + main_layout = QVBoxLayout() + main_layout.addWidget(self.scroll_area, 1) + main_layout.addLayout(btn_layout) + self.setLayout(main_layout) + + if not self.pixmap.isNull(): + filename = Path(image_path_or_url).name + self.setWindowTitle(f"{filename} ({self.pixmap.width()}x{self.pixmap.height()}px) - {self.t('image_preview')}") + self._zoom_fit() + else: + self.image_label.setText(f"{image_path_or_url}") + + def _load_pixmap(self, image_path_or_url: str, base_dir: Path | None) -> QPixmap: + path_str = image_path_or_url + if path_str.startswith("file:///"): + path_str = path_str[8:] + elif path_str.startswith("file://"): + path_str = path_str[7:] + + target_path = Path(path_str) + if not target_path.is_absolute() and base_dir: + target_path = base_dir / target_path + + if target_path.exists(): + return QPixmap(str(target_path)) + pixmap = QPixmap() + if pixmap.load(image_path_or_url): + return pixmap + return QPixmap() + + def _update_image_size(self) -> None: + if self.pixmap.isNull(): + return + new_size = self.pixmap.size() * self.scale_factor + self.image_label.resize(new_size) + + def _zoom_in(self) -> None: + self.scale_factor *= 1.25 + self._update_image_size() + + def _zoom_out(self) -> None: + self.scale_factor /= 1.25 + self._update_image_size() + + def _zoom_reset(self) -> None: + self.scale_factor = 1.0 + self._update_image_size() + + def _zoom_fit(self) -> None: + if self.pixmap.isNull(): + return + viewport_size = self.scroll_area.viewport().size() + w_ratio = viewport_size.width() / self.pixmap.width() if self.pixmap.width() > 0 else 1.0 + h_ratio = viewport_size.height() / self.pixmap.height() if self.pixmap.height() > 0 else 1.0 + self.scale_factor = min(w_ratio, h_ratio, 1.0) + self._update_image_size() + + def _copy_image(self) -> None: + if not self.pixmap.isNull(): + QApplication.clipboard().setPixmap(self.pixmap) + + class SettingsStore: def __init__(self) -> None: base_dir = Path(os.environ.get("APPDATA", Path.home() / ".config")) / APP_NAME @@ -921,8 +1041,16 @@ def _toggle_editor_toolbar(self) -> None: self._set_editor_toolbar_collapsed(not self.settings.editor_toolbar_collapsed) def _open_anchor(self, url: QUrl) -> None: - if url.isValid(): - QDesktopServices.openUrl(url) + if not url.isValid(): + return + url_str = url.toString() + path_obj = Path(url.toLocalFile() if url.isLocalFile() else url.path()) + suffix = path_obj.suffix.lower() + if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".ico"}: + dialog = ImagePreviewDialog(url_str, self.t, base_dir=self._session_asset_dir, parent=self) + dialog.exec() + return + QDesktopServices.openUrl(url) def _on_text_changed(self) -> None: self.is_modified = not self._is_blank_untitled_document() @@ -1032,11 +1160,38 @@ def render_inline(match: re.Match[str]) -> str: masked_text = re.sub(r"\\\((.+?)\\\)", render_inline, masked_text) return self._restore_protected_regions(masked_text, protected) + def _render_figures_and_captions(self, body: str) -> str: + """Wandelt -Tags in strukturierte
/
-Bloecke um und macht sie im Viewer anklickbar.""" + + def repl_img(match: re.Match[str]) -> str: + full_tag = match.group(0) + src_m = re.search(r'src=["\']([^"\']+)["\']', full_tag) + alt_m = re.search(r'alt=["\']([^"\']+)["\']', full_tag) + title_m = re.search(r'title=["\']([^"\']+)["\']', full_tag) + + src = src_m.group(1) if src_m else "" + alt = alt_m.group(1) if alt_m else "" + title = title_m.group(1) if title_m else "" + + caption = title if title else alt + img_html = full_tag + if alt and not title_m: + img_html = img_html.replace('alt="', f'title="{html.escape(alt)}" alt="') + + linked_img = f'{img_html}' + if caption: + return f'
{linked_img}
{html.escape(caption)}
' + return f'
{linked_img}
' + + body = re.sub(r'

\s*(?:]*>)?\s*]+>\s*(?:)?\s*

', repl_img, body) + return body + def _render_markdown_body(self, text: str) -> str: text = self._inject_math_markup(text) body = markdown.markdown(text, extensions=["extra", "sane_lists", "footnotes"]) body = self._render_task_lists(body) body = self._render_strikethrough(body) + body = self._render_figures_and_captions(body) return body def _wrap_html_document(self, body: str, theme: str) -> str: @@ -1593,10 +1748,28 @@ def _insert_link(self) -> None: self.editor.setFocus() def _insert_image(self) -> None: - image_url, ok = QInputDialog.getText(self, self.t("image"), self.t("image_url")) - if not ok or not image_url.strip(): - return - alt_text, ok = QInputDialog.getText(self, self.t("image"), self.t("image_alt")) + file_name, _ = QFileDialog.getOpenFileName( + self, + self.t("select_image_file"), + str(self.current_file.parent if self.current_file else Path.home()), + "Images (*.png *.jpg *.jpeg *.gif *.webp *.svg *.bmp);;All Files (*.*)", + ) + image_url = file_name + if not image_url: + url_input, ok = QInputDialog.getText(self, self.t("image"), self.t("image_url")) + if not ok or not url_input.strip(): + return + image_url = url_input.strip() + else: + if self.current_file: + try: + rel_path = os.path.relpath(image_url, self.current_file.parent) + image_url = rel_path.replace("\\", "/") + except ValueError: + pass + + default_alt = Path(image_url).stem if Path(image_url).stem else "image" + alt_text, ok = QInputDialog.getText(self, self.t("image"), self.t("image_alt"), text=default_alt) if not ok: return self._insert_text(f"![{alt_text.strip()}]({image_url.strip()})") @@ -1720,6 +1893,9 @@ def fake_save(*args, **kwargs): results.append(("math_markup_classes", 'class="math-inline"' in math_markup and 'class="math-block"' in math_markup)) results.append(("math_code_protection", "$code$" in math_markup and "`$roh$`" in math_markup)) + fig_body = window._render_figures_and_captions('

Test Caption

') + results.append(("figure_caption_rendering", "
" in fig_body and "
Test Caption
" in fig_body and '' in fig_body)) + results.append(("editor_toolbar_default_visible", window.tabs.widget(0) is window.viewer)) results.append(("editor_highlighter_ready", window.editor_highlighter is not None)) results.append(("readme_screenshot_present", screenshot_path.is_file() and screenshot_path.stat().st_size > 0)) diff --git a/tests/conftest.py b/tests/conftest.py index d434122..64b590f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,6 +68,7 @@ def render_helpers(main_module): helpers._restore_protected_regions = main_window_cls._restore_protected_regions.__get__(helpers) helpers._render_task_lists = main_window_cls._render_task_lists.__get__(helpers) helpers._render_strikethrough = main_window_cls._render_strikethrough.__get__(helpers) + helpers._render_figures_and_captions = main_window_cls._render_figures_and_captions.__get__(helpers) helpers._inject_math_markup = main_window_cls._inject_math_markup.__get__(helpers) return helpers @@ -84,4 +85,5 @@ def render_to_body(helpers, text: str) -> str: text = helpers._inject_math_markup(text) body = markdown.markdown(text, extensions=["extra", "sane_lists", "footnotes"]) body = helpers._render_task_lists(body) - return helpers._render_strikethrough(body) + body = helpers._render_strikethrough(body) + return helpers._render_figures_and_captions(body) diff --git a/tests/test_markdown_rendering.py b/tests/test_markdown_rendering.py index f2b8ead..b627084 100644 --- a/tests/test_markdown_rendering.py +++ b/tests/test_markdown_rendering.py @@ -440,3 +440,17 @@ def test_strip_markdown_formatting_is_idempotent(main_module): f"Nicht idempotent:\n Eingabe: {sample!r}\n" f" 1× Strip: {once!r}\n 2× Strip: {twice!r}" ) + + +def test_render_figures_and_captions_wraps_images_in_figure_and_anchor(render_helpers): + html_in = '

System Diagram

' + rendered = render_helpers._render_figures_and_captions(html_in) + assert "
" in rendered + assert '
System Diagram
' in rendered + assert '
' in rendered + + +def test_render_figures_and_captions_uses_title_over_alt_if_present(render_helpers): + html_in = '

Alt text

' + rendered = render_helpers._render_figures_and_captions(html_in) + assert '
Title text
' in rendered