Skip to content

Commit 0cd13f1

Browse files
committed
fix: tighten image-caption spacing in rendered markdown
1 parent 9ee8166 commit 0cd13f1

3 files changed

Lines changed: 80 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.4.2] - 2026-04-19
9+
10+
### Fixed
11+
12+
- Reduced excessive spacing between images and captions. Added dedicated handling for paragraphs that contain image + caption content in a single block, removed injected `<br>` spacing in that case, and applied tighter caption typography/spacing classes.
13+
814
## [1.4.1] - 2026-04-19
915

1016
### Fixed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "md2html-tailwind4"
7-
version = "1.4.1"
7+
version = "1.4.2"
88
description = "A Python package that converts Markdown to HTML using Tailwind CSS 4 for styling."
99
authors = [
1010
{ name = "White Neuron Co., Ltd.", email = "contact@whiteneuron.com" },

src/md2html_tailwind4/converter.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ def add_tailwind_classes(self, soup):
120120
for p in soup.find_all('p'):
121121
if self._is_badge_paragraph(p):
122122
p['class'] = 'flex flex-wrap gap-2 items-center justify-center mb-4'
123+
elif self._is_image_with_caption_paragraph(p):
124+
p['class'] = 'mb-4 text-center'
125+
for br in p.find_all('br'):
126+
br.decompose()
127+
elif self._is_image_only_paragraph(p):
128+
p['class'] = 'mb-1'
129+
elif self._is_image_caption_paragraph(p):
130+
p['class'] = 'mt-1 mb-4 text-center text-sm leading-6 text-neutral-600 dark:text-neutral-400'
123131
else:
124132
p['class'] = f'mb-4 {s["body_size"]} leading-7 text-pretty text-justify text-neutral-800 dark:text-neutral-200'
125133

@@ -139,7 +147,7 @@ def add_tailwind_classes(self, soup):
139147
if img.parent and img.parent.name == 'a':
140148
img['class'] = 'inline h-5 align-middle'
141149
else:
142-
img['class'] = 'mx-auto my-5 w-full max-w-4xl h-auto rounded-xl border border-neutral-200 shadow-sm dark:border-neutral-700 dark:shadow-neutral-950/30'
150+
img['class'] = 'mx-auto my-2 w-full max-w-4xl h-auto rounded-xl border border-neutral-200 shadow-sm dark:border-neutral-700 dark:shadow-neutral-950/30'
143151

144152
for pre in soup.find_all('pre'):
145153
pre['class'] = f'my-5 overflow-x-auto rounded-xl border border-neutral-200 bg-neutral-950 p-4 {s["pre_size"]} leading-6 text-neutral-100 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100'
@@ -154,7 +162,12 @@ def add_tailwind_classes(self, soup):
154162
strong['class'] = 'font-semibold text-neutral-900 dark:text-neutral-100'
155163

156164
for em in soup.find_all('em'):
157-
em['class'] = 'italic text-neutral-700 dark:text-neutral-300'
165+
if em.parent and em.parent.name == 'p' and self._is_image_with_caption_paragraph(em.parent):
166+
em['class'] = 'mt-1 block text-sm italic text-neutral-600 dark:text-neutral-400'
167+
elif em.parent and em.parent.name == 'p' and self._is_image_caption_paragraph(em.parent):
168+
em['class'] = 'italic text-inherit'
169+
else:
170+
em['class'] = 'italic text-neutral-700 dark:text-neutral-300'
158171

159172
for a in soup.find_all('a'):
160173
a_cls = a.get('class') or []
@@ -328,6 +341,64 @@ def _is_badge_paragraph(p):
328341
return False
329342
return True
330343

344+
@staticmethod
345+
def _is_image_only_paragraph(p):
346+
"""Return True if a paragraph contains only a single image (optionally wrapped by a link)."""
347+
children = [c for c in p.children if getattr(c, 'name', None) or str(c).strip()]
348+
if len(children) != 1:
349+
return False
350+
child = children[0]
351+
name = getattr(child, 'name', None)
352+
if name == 'img':
353+
return True
354+
if name == 'a':
355+
a_children = [c for c in child.children if getattr(c, 'name', None) or str(c).strip()]
356+
return len(a_children) == 1 and getattr(a_children[0], 'name', None) == 'img'
357+
return False
358+
359+
@staticmethod
360+
def _is_image_with_caption_paragraph(p):
361+
"""Return True if a paragraph contains an image and an emphasized caption line."""
362+
meaningful = [c for c in p.children if getattr(c, 'name', None) or str(c).strip()]
363+
names = {getattr(c, 'name', None) for c in meaningful if getattr(c, 'name', None)}
364+
if not meaningful:
365+
return False
366+
367+
has_image = False
368+
for child in meaningful:
369+
name = getattr(child, 'name', None)
370+
if name == 'img':
371+
has_image = True
372+
break
373+
if name == 'a':
374+
a_children = [c for c in child.children if getattr(c, 'name', None) or str(c).strip()]
375+
if len(a_children) == 1 and getattr(a_children[0], 'name', None) == 'img':
376+
has_image = True
377+
break
378+
379+
if not has_image or 'em' not in names:
380+
return False
381+
382+
# Allow only image/link, caption (<em>), line breaks and whitespace text nodes.
383+
for child in meaningful:
384+
name = getattr(child, 'name', None)
385+
if name in ('img', 'em', 'br'):
386+
continue
387+
if name == 'a':
388+
a_children = [c for c in child.children if getattr(c, 'name', None) or str(c).strip()]
389+
if len(a_children) == 1 and getattr(a_children[0], 'name', None) == 'img':
390+
continue
391+
if not name and not str(child).strip():
392+
continue
393+
return False
394+
return True
395+
396+
@staticmethod
397+
def _is_image_caption_paragraph(p):
398+
"""Return True if a paragraph is an image caption rendered as only emphasized text."""
399+
children = [c for c in p.children if getattr(c, 'name', None) or str(c).strip()]
400+
return bool(children) and all(getattr(c, 'name', None) == 'em' for c in children)
401+
331402
@staticmethod
332403
def _ensure_list_spacing(text):
333404
"""Insert a blank line before list/blockquote lines that immediately follow non-blank content.

0 commit comments

Comments
 (0)