(mermaid/pre-header). Everything else is untrusted input.
- const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div|span)([\s>]|$)/i;
+ const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|img|div|span)([\s>]|$)/i;
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
// Autolink: convert plain URLs to clickable links.
// Stash existing
tags first so we never re-link a URL already inside href="...".
diff --git a/tests/test_issue486_487.py b/tests/test_issue486_487.py
new file mode 100644
index 0000000..822c726
--- /dev/null
+++ b/tests/test_issue486_487.py
@@ -0,0 +1,572 @@
+"""
+Tests for issue #486 (CSS: inline code in table cells) and
+issue #487 (JS renderer: markdown image syntax not implemented).
+
+Issue #486 — CSS fix in static/style.css:
+ Inline `code` spans inside table cells render with awkward sizing.
+ Fix: td code, th code { font-size: 0.85em; padding: 1px 4px; vertical-align: baseline; }
+
+Issue #487 — JS fix in static/ui.js:
+  image syntax not handled — renders as stray ! + link.
+ Fix: add image pass to renderMd() (before link pass) and inlineMd()
+ reusing the .msg-media-img class.
+
+Strategy:
+ - Source-level checks verify the fixes are present in the JS/CSS.
+ - Python mirror tests verify the rendering logic with exhaustive edge cases,
+ especially code blocks inside tables (the specific case Nathan flagged).
+"""
+import pathlib
+import re
+import html as _html
+
+REPO_ROOT = pathlib.Path(__file__).parent.parent
+UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
+STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def esc(s):
+ return _html.escape(str(s), quote=True)
+
+
+def inline_md(t):
+ """
+ Python mirror of the fixed inlineMd() function — includes:
+ - _code_stash (protects backtick spans from bold/italic AND from image pass)
+ - image pass (NEW for #487 — runs while code stash is active, before link pass)
+ - _img_stash (protects rendered img tags from autolink touching src=)
+ - _link_stash (protects links from autolink)
+ - autolink
+ - code stash restore (after autolink, so code content is never autolinked)
+
+ Correct operation order:
+ 1. code stash — \x00C protects `...` from bold and image pass
+ 2. bold/italic — runs on plain text only
+ 3. image pass — runs while code content is still stashed (so 
+ inside backticks stays protected as a \x00C token)
+ 4. img stash — \x00I protects
from autolink
+ 5. link stash — \x00L protects [label](url) links from autolink
+ 6. autolink — only matches URLs not already in a stash token
+ 7. link stash restore
+ 8. img stash restore
+ 9. code stash restore — restores tags last
+ """
+ # 1. Code stash — must be first to protect code content from all subsequent passes
+ code_stash = []
+ def stash_code(m):
+ code_stash.append(f'{esc(m.group(1))}')
+ return f'\x00C{len(code_stash)-1}\x00'
+ t = re.sub(r'`([^`\n]+)`', stash_code, t)
+
+ # 2. Bold/italic (code content is safely stashed)
+ t = re.sub(r'\*\*\*(.+?)\*\*\*', lambda m: f'{esc(m.group(1))}', t)
+ t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'{esc(m.group(1))}', t)
+ t = re.sub(r'\*([^*\n]+)\*', lambda m: f'{esc(m.group(1))}', t)
+
+ # 3. Image pass (NEW — runs while code is still stashed, so  inside
+ # backticks is protected as a \x00C token and won't match here)
+ def render_image(m):
+ alt, url = m.group(1), m.group(2)
+ safe_url = url.replace('"', '%22')
+ return (f'
')
+ t = re.sub(r'!\[([^\]]*)\]\((https?://[^\)]+)\)', render_image, t)
+
+ # 4. Img stash — protect rendered
tags so autolink never touches src= values
+ img_stash = []
+ def stash_img(m):
+ img_stash.append(m.group(0))
+ return f'\x00I{len(img_stash)-1}\x00'
+ t = re.sub(r'
]*>', stash_img, t)
+
+ # 5. Link stash
+ link_stash = []
+ def stash_link(m):
+ lb, u = m.group(1), m.group(2)
+ link_stash.append(f'{esc(lb)}')
+ return f'\x00L{len(link_stash)-1}\x00'
+ t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_link, t)
+
+ # 6. Autolink (img and link URLs are both stashed — safe)
+ def autolink(m):
+ url = m.group(1)
+ trail = url[-1] if url[-1] in '.,;:!?)' else ''
+ clean = url[:-1] if trail else url
+ return f'{esc(clean)}{trail}'
+ t = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, t)
+
+ # 7. Restore link stash
+ t = re.sub(r'\x00L(\d+)\x00', lambda m: link_stash[int(m.group(1))], t)
+
+ # 8. Restore img stash
+ t = re.sub(r'\x00I(\d+)\x00', lambda m: img_stash[int(m.group(1))], t)
+
+ # 9. Restore code stash (last — code content was never touched by any pass)
+ t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
+ return t
+
+
+def render_table(md):
+ """Python mirror of the table pass, using inline_md() per cell."""
+ lines = md.strip().split('\n')
+ if len(lines) < 2:
+ return md
+
+ def is_sep(r):
+ return bool(re.match(r'^\|[\s|:-]+\|$', r.strip()))
+
+ if not is_sep(lines[1]):
+ return md
+
+ def parse_header(r):
+ cells = r.strip().lstrip('|').rstrip('|').split('|')
+ return ''.join(f'{inline_md(c.strip())} | ' for c in cells)
+
+ def parse_row(r):
+ cells = r.strip().lstrip('|').rstrip('|').split('|')
+ return ''.join(f'{inline_md(c.strip())} | ' for c in cells)
+
+ header = f'{parse_header(lines[0])}
'
+ body = ''.join(f'{parse_row(r)}
' for r in lines[2:])
+ return f''
+
+
+# ═════════════════════════════════════════════════════════════════════════════
+# ISSUE #486 — CSS: code inside table cells
+# ═════════════════════════════════════════════════════════════════════════════
+
+class TestIssue486CssCodeInTable:
+ """CSS fix: td code and th code must have targeted sizing rules."""
+
+ def test_td_code_font_size_present(self):
+ """msg-body td code rule must set font-size (e.g. 0.85em) to prevent oversized code."""
+ assert 'td code' in STYLE_CSS, (
+ "Missing 'td code' CSS rule — inline code in table cells needs sizing fix"
+ )
+
+ def test_th_code_rule_present(self):
+ """th code rule must also exist for header cells."""
+ assert 'th code' in STYLE_CSS, (
+ "Missing 'th code' CSS rule — inline code in header cells needs sizing fix"
+ )
+
+ def test_td_code_has_font_size(self):
+ """The td code / th code block must include a font-size declaration."""
+ # Find the msg-body scoped td code rule
+ idx = STYLE_CSS.find('td code')
+ assert idx != -1, "td code rule not found in style.css"
+ # Check nearby text (within 200 chars) has font-size
+ window = STYLE_CSS[idx:idx+200]
+ assert 'font-size' in window, (
+ f"td code rule must include font-size. Found near td code: {window!r}"
+ )
+
+ def test_td_code_has_padding(self):
+ """The td code / th code block must include a padding declaration."""
+ idx = STYLE_CSS.find('td code')
+ assert idx != -1
+ window = STYLE_CSS[idx:idx+200]
+ assert 'padding' in window, (
+ f"td code rule must include padding. Found near td code: {window!r}"
+ )
+
+ def test_td_code_has_vertical_align(self):
+ """The td code / th code block must include vertical-align: baseline."""
+ idx = STYLE_CSS.find('td code')
+ assert idx != -1
+ window = STYLE_CSS[idx:idx+200]
+ assert 'vertical-align' in window, (
+ f"td code rule must include vertical-align. Found near td code: {window!r}"
+ )
+
+ def test_code_renders_inside_table_cell(self):
+ """Inline `code` inside a table cell must render as element."""
+ md = "| Syntax | Rendered |\n|---|---|\n| `code` | `code` |"
+ result = render_table(md)
+ assert 'code' in result, (
+ f"Inline code in table cell should render as . Got: {result}"
+ )
+
+ def test_bold_code_renders_inside_table_cell(self):
+ """**`bold code`** inside a table cell must render as ."""
+ md = "| Style | Example |\n|---|---|\n| bold code | **`bold code`** |"
+ result = render_table(md)
+ # Should have code tag (even inside bold)
+ assert 'bold code' in result, (
+ f"Bold code in table should render as . Got: {result}"
+ )
+
+ def test_multiple_code_spans_in_same_cell(self):
+ """Multiple backtick spans in one cell all render as ."""
+ md = "| Combined |\n|---|\n| `a` and `b` |"
+ result = render_table(md)
+ assert result.count('') == 2, (
+ f"Expected 2 code tags in cell, got: {result}"
+ )
+
+ def test_code_in_header_cell(self):
+ """`code` in a header cell must also render as ."""
+ md = "| `header code` | Normal |\n|---|---|\n| data | data |"
+ result = render_table(md)
+ assert 'header code' in result, (
+ f"Code in header cell should render. Got: {result}"
+ )
+
+ def test_code_not_mangled_by_bold_in_table(self):
+ """**`code`** in a table cell must NOT produce <code> (the pre-fix bug)."""
+ md = "| Pattern | Example |\n|---|---|\n| bold-code | **`npm install`** |"
+ result = render_table(md)
+ assert '<code>' not in result, (
+ f"Code tags inside bold in table must not be HTML-escaped. Got: {result}"
+ )
+ assert '' in result, "Bold wrapper should be present"
+ assert 'npm install' in result
+
+ def test_code_with_special_chars_in_table(self):
+ """` |