fix: code-in-table CSS sizing + markdown image rendering (#486, #487)

- static/style.css: add td code / th code rules (font-size 0.85em,
  padding 1px 4px, vertical-align baseline) for both .msg-body and
  .preview-md to fix cramped inline code in table cells (#486)

- static/ui.js inlineMd(): add image pass (![alt](url) → <img
  class=msg-media-img>) running while _code_stash is active (protects
  image syntax inside backticks), add _img_stash (\x00G) to shield
  rendered <img> src= from autolink, add img to SAFE_INLINE (#487)

- static/ui.js renderMd() outer: add image pass before outer link pass
  for images in plain paragraphs, add img to SAFE_TAGS allowlist (#487)

- tests/test_issue486_487.py: 45 new tests covering CSS source checks,
  JS source structure, rendering behaviour, and combination edge cases
  (code + image + link in same table cell, image inside code span, etc.)

Closes #486, closes #487
This commit is contained in:
Hermes Agent
2026-04-14 21:52:34 +00:00
parent 75de03c99f
commit 887893ecd1
5 changed files with 640 additions and 4 deletions

572
tests/test_issue486_487.py Normal file
View File

@@ -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:
![alt](url) 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 ![x](url)
inside backticks stays protected as a \x00C token)
4. img stash — \x00I protects <img src="url"> 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 <code> 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'<code>{esc(m.group(1))}</code>')
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'<strong><em>{esc(m.group(1))}</em></strong>', t)
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
t = re.sub(r'\*([^*\n]+)\*', lambda m: f'<em>{esc(m.group(1))}</em>', t)
# 3. Image pass (NEW — runs while code is still stashed, so ![x](url) 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'<img src="{safe_url}" alt="{esc(alt)}" '
f'class="msg-media-img" loading="lazy" '
f'onclick="this.classList.toggle(\'msg-media-img--full\')">')
t = re.sub(r'!\[([^\]]*)\]\((https?://[^\)]+)\)', render_image, t)
# 4. Img stash — protect rendered <img> 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'<img\b[^>]*>', stash_img, t)
# 5. Link stash
link_stash = []
def stash_link(m):
lb, u = m.group(1), m.group(2)
link_stash.append(f'<a href="{u.replace(chr(34), "%22")}" target="_blank" rel="noopener">{esc(lb)}</a>')
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'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{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'<th>{inline_md(c.strip())}</th>' for c in cells)
def parse_row(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<td>{inline_md(c.strip())}</td>' for c in cells)
header = f'<tr>{parse_header(lines[0])}</tr>'
body = ''.join(f'<tr>{parse_row(r)}</tr>' for r in lines[2:])
return f'<table><thead>{header}</thead><tbody>{body}</tbody></table>'
# ═════════════════════════════════════════════════════════════════════════════
# 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 <code> element."""
md = "| Syntax | Rendered |\n|---|---|\n| `code` | `code` |"
result = render_table(md)
assert '<code>code</code>' in result, (
f"Inline code in table cell should render as <code>. Got: {result}"
)
def test_bold_code_renders_inside_table_cell(self):
"""**`bold code`** inside a table cell must render as <strong><code>."""
md = "| Style | Example |\n|---|---|\n| bold code | **`bold code`** |"
result = render_table(md)
# Should have code tag (even inside bold)
assert '<code>bold code</code>' in result, (
f"Bold code in table should render as <code>. Got: {result}"
)
def test_multiple_code_spans_in_same_cell(self):
"""Multiple backtick spans in one cell all render as <code>."""
md = "| Combined |\n|---|\n| `a` and `b` |"
result = render_table(md)
assert result.count('<code>') == 2, (
f"Expected 2 code tags in cell, got: {result}"
)
def test_code_in_header_cell(self):
"""`code` in a <th> header cell must also render as <code>."""
md = "| `header code` | Normal |\n|---|---|\n| data | data |"
result = render_table(md)
assert '<code>header code</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 &lt;code&gt; (the pre-fix bug)."""
md = "| Pattern | Example |\n|---|---|\n| bold-code | **`npm install`** |"
result = render_table(md)
assert '&lt;code&gt;' not in result, (
f"Code tags inside bold in table must not be HTML-escaped. Got: {result}"
)
assert '<strong>' in result, "Bold wrapper should be present"
assert '<code>npm install</code>' in result
def test_code_with_special_chars_in_table(self):
"""`<script>` inside a table cell must have the angle brackets escaped."""
md = "| Input | Output |\n|---|---|\n| `<script>` | sanitized |"
result = render_table(md)
assert '&lt;script&gt;' in result, (
f"Code content must be HTML-escaped. Got: {result}"
)
# The <code> wrapper itself must be there
assert '<code>' in result
def test_code_adjacent_to_link_in_table(self):
"""`code` and [link](url) in same cell both render correctly."""
url = 'https://example.com'
md = f"| Mixed |\n|---|\n| `foo` and [bar]({url}) |"
result = render_table(md)
assert '<code>foo</code>' in result
assert f'href="{url}"' in result
assert 'bar' in result
def test_empty_code_span_in_table(self):
"""Edge case: empty backtick span in table cell (`` ` ` ``) — no crash."""
# This won't match the code regex (requires at least 1 char), should pass through
md = "| Col |\n|---|\n| normal text |"
result = render_table(md)
assert '<td>normal text</td>' in result
# ═════════════════════════════════════════════════════════════════════════════
# ISSUE #487 — JS renderer: markdown image syntax
# ═════════════════════════════════════════════════════════════════════════════
class TestIssue487ImageRendering:
"""Image syntax ![alt](url) must render as <img>, not as ! + link."""
# ── Source-level checks ──────────────────────────────────────────────────
def test_image_pass_present_in_ui_js(self):
"""renderMd() must contain an image regex pass for ![alt](url)."""
assert '![' in UI_JS or r'!\[' in UI_JS, (
"ui.js should contain image syntax handling (![...](url) regex)"
)
# More specifically, look for the img tag being generated
assert 'msg-media-img' in UI_JS, (
"Image pass should reuse .msg-media-img class"
)
def test_image_pass_runs_before_link_pass_in_outer(self):
"""Image regex must appear in ui.js BEFORE the [label](url) link pass."""
# Find the image pass position
img_idx = UI_JS.find('!\\[')
if img_idx == -1:
img_idx = UI_JS.find("![")
# Find the outer labeled link pass position (after table pass)
link_idx = UI_JS.find("Outer link pass for labeled links")
assert img_idx != -1, "Image pass not found in ui.js"
assert link_idx != -1, "Outer link pass comment not found in ui.js"
assert img_idx < link_idx, (
"Image pass must run before the outer [label](url) link pass "
"to prevent the image from being consumed as a plain link"
)
def test_image_url_sanitized_for_quotes(self):
"""Image src URL must have double-quotes percent-encoded."""
# The image pass must use .replace(/"/g,'%22') or equivalent
# Look for the pattern near image handling
img_idx = UI_JS.find('msg-media-img')
assert img_idx != -1
# Find all occurrences — there's the MEDIA restore and the new image pass
# The new one should have %22 for URL sanitization
assert '%22' in UI_JS, (
"Image src URL must sanitize double-quotes to %22"
)
def test_image_alt_uses_esc(self):
"""Alt text must be passed through esc() to prevent XSS."""
# Look for esc( call near the image rendering code
# The pattern should be: alt="${esc(alt)}"
assert 'esc(' in UI_JS, "esc() function must be used for alt text"
def test_safe_tags_includes_img(self):
"""SAFE_TAGS allowlist must include 'img' to prevent the tag from being escaped."""
# Find the SAFE_TAGS regex in ui.js
safe_idx = UI_JS.find('SAFE_TAGS=')
assert safe_idx != -1, "SAFE_TAGS not found in ui.js"
safe_window = UI_JS[safe_idx:safe_idx+300]
assert 'img' in safe_window, (
f"SAFE_TAGS must include 'img' tag. Found: {safe_window!r}"
)
def test_inlinemd_has_image_pass(self):
"""inlineMd() must also handle ![alt](url) for images inside table cells."""
# inlineMd is called for table cells, list items, blockquotes
# Find inlineMd function body
start = UI_JS.find('function inlineMd(')
assert start != -1, "inlineMd function not found"
# Get a generous window covering the function
fn_window = UI_JS[start:start+1500]
assert '![' in fn_window or r'!\[' in fn_window, (
"inlineMd() must handle image syntax for images in table cells"
)
# ── Behaviour tests (Python mirror) ─────────────────────────────────────
def test_basic_image_renders_as_img_tag(self):
"""![alt](https://example.com/img.png) must produce an <img> tag."""
t = '![A cat](https://example.com/cat.png)'
result = inline_md(t)
assert '<img ' in result, f"Expected <img> tag, got: {result}"
assert 'src="https://example.com/cat.png"' in result
assert 'alt="A cat"' in result
# Must NOT have the raw ![...] syntax left over
assert '![' not in result
# Must NOT have a stray ! character
assert result.startswith('<img '), f"Result should start with img tag: {result}"
def test_image_does_not_render_as_link(self):
"""![alt](url) must NOT produce an <a> tag (the pre-fix bug)."""
t = '![Logo](https://example.com/logo.png)'
result = inline_md(t)
assert '<a ' not in result, (
f"Image must not render as an <a> tag. Got: {result}"
)
def test_image_stray_exclamation_not_present(self):
"""No stray ! character before the img tag (the pre-fix symptom)."""
t = '![alt](https://example.com/img.png)'
result = inline_md(t)
# Strip the img tag and check no ! is left
cleaned = re.sub(r'<img[^>]+>', '', result)
assert '!' not in cleaned, (
f"Stray ! character present after image render. Got: {result}"
)
def test_image_uses_msg_media_img_class(self):
"""Rendered <img> must use class=\"msg-media-img\" for consistent styling."""
t = '![screenshot](https://example.com/shot.png)'
result = inline_md(t)
assert 'class="msg-media-img"' in result, (
f"Image must use .msg-media-img class. Got: {result}"
)
def test_image_has_lazy_loading(self):
"""Rendered <img> must have loading=\"lazy\"."""
t = '![x](https://example.com/x.png)'
result = inline_md(t)
assert 'loading="lazy"' in result, f"Expected loading=lazy. Got: {result}"
def test_image_has_click_to_zoom(self):
"""Rendered <img> must have onclick toggle for zoom."""
t = '![x](https://example.com/x.png)'
result = inline_md(t)
assert 'msg-media-img--full' in result, (
f"Image must have click-to-zoom onclick. Got: {result}"
)
def test_image_alt_is_escaped(self):
"""Alt text with HTML special chars must be escaped."""
t = '![<evil>](https://example.com/img.png)'
result = inline_md(t)
assert '&lt;evil&gt;' in result, (
f"Alt text must be HTML-escaped. Got: {result}"
)
assert '<evil>' not in result
def test_image_url_quote_sanitized(self):
"""Double-quote in image URL must be percent-encoded to prevent attribute breakout."""
t = '![x](https://example.com/path"with"quotes.png)'
result = inline_md(t)
# Find the src attribute value
src_match = re.search(r'src="([^"]*)"', result)
assert src_match, f"src attribute not found. Got: {result}"
src_val = src_match.group(1)
assert '"' not in src_val, (
f"Raw double-quote in src would break attribute. Got src: {src_val!r}"
)
def test_image_no_javascript_uri(self):
"""javascript: URIs must not be rendered as image src (regex only matches http/https)."""
t = '![x](javascript:alert(1))'
result = inline_md(t)
# The regex requires https?://, so this should pass through unmodified
assert '<img ' not in result, (
f"javascript: URI must not render as <img>. Got: {result}"
)
def test_image_no_data_uri(self):
"""data: URIs must not be rendered as image src."""
t = '![x](data:image/png;base64,abc123)'
result = inline_md(t)
assert '<img ' not in result, (
f"data: URI must not render as <img>. Got: {result}"
)
def test_image_followed_by_text(self):
"""Image followed by plain text — only the image becomes an <img>."""
t = '![cat](https://example.com/cat.png) and some text'
result = inline_md(t)
assert '<img ' in result
assert 'and some text' in result
def test_image_preceded_by_text(self):
"""Text before an image — both render correctly."""
t = 'Here is a screenshot: ![shot](https://example.com/shot.png)'
result = inline_md(t)
assert 'Here is a screenshot:' in result
assert '<img ' in result
def test_image_and_link_in_same_cell(self):
"""Image and link in same inline context both render correctly."""
t = '![img](https://example.com/img.png) see [here](https://example.com)'
result = inline_md(t)
assert '<img ' in result
assert '<a href="https://example.com"' in result
assert '![' not in result
def test_image_inside_table_cell(self):
"""![alt](url) inside a markdown table cell must render as <img>."""
md = ("| Image | Caption |\n"
"|---|---|\n"
"| ![logo](https://example.com/logo.png) | Company logo |")
result = render_table(md)
assert '<img ' in result, f"Image in table should render as <img>. Got: {result}"
assert 'src="https://example.com/logo.png"' in result
assert '<a ' not in result, "Image in table must not render as <a>"
def test_image_in_table_no_stray_exclamation(self):
"""No stray ! before the <img> when image is inside a table cell."""
md = ("| X |\n|---|\n| ![x](https://x.com/x.png) |")
result = render_table(md)
# Strip known tags and check no ! appears
cleaned = re.sub(r'<[^>]+>', '', result)
assert '!' not in cleaned, (
f"Stray ! in table cell after image render. Cleaned: {cleaned!r}"
)
def test_empty_alt_text_image(self):
"""![](url) with empty alt renders as <img> with empty alt attribute."""
t = '![](https://example.com/img.png)'
result = inline_md(t)
assert '<img ' in result
assert 'alt=""' in result
def test_multiple_images_in_one_cell(self):
"""Two images in one table cell both render as <img> tags."""
t = ('![a](https://example.com/a.png) '
'![b](https://example.com/b.png)')
result = inline_md(t)
assert result.count('<img ') == 2, (
f"Expected 2 img tags. Got: {result}"
)
def test_image_with_https_url(self):
"""https:// image URL renders correctly."""
t = '![secure](https://secure.example.com/img.jpg)'
result = inline_md(t)
assert 'src="https://secure.example.com/img.jpg"' in result
def test_image_with_http_url(self):
"""http:// image URL also renders (non-https still valid)."""
t = '![old](http://example.com/img.jpg)'
result = inline_md(t)
assert '<img ' in result
assert 'src="http://example.com/img.jpg"' in result
# ═════════════════════════════════════════════════════════════════════════════
# Cross-cutting: code + image together inside tables (the edge case Nathan flagged)
# ═════════════════════════════════════════════════════════════════════════════
class TestEdgeCasesCodeAndImageInTables:
"""Combination edge cases: code blocks and images mixed inside table cells."""
def test_code_and_image_in_same_table_row(self):
"""Table row with code in one cell and image in another renders both correctly."""
md = ("| Code | Preview |\n"
"|---|---|\n"
"| `print('hello')` | ![screenshot](https://example.com/shot.png) |")
result = render_table(md)
assert "<code>print(&#x27;hello&#x27;)</code>" in result or "<code>print('hello')</code>" in result, (
f"Code cell should render as <code>. Got: {result}"
)
assert '<img ' in result, "Image cell should render as <img>"
def test_code_in_cell_with_image_in_next_cell(self):
"""Multiple columns: code stays code, image stays image, no cross-contamination."""
md = ("| Step | Example |\n"
"|---|---|\n"
"| Run `npm install` | ![demo](https://example.com/demo.gif) |")
result = render_table(md)
assert '<code>npm install</code>' in result
assert '<img ' in result
assert '<a ' not in result # image must not become a link
def test_bold_code_in_cell_and_image_in_cell(self):
"""**`code`** in one cell and image in another — no esc() mangling."""
md = ("| Command | Result |\n"
"|---|---|\n"
"| **`git status`** | ![result](https://example.com/r.png) |")
result = render_table(md)
assert '&lt;code&gt;' not in result, (
"Bold+code in table cell must not produce escaped code tags"
)
assert '<code>git status</code>' in result
assert '<img ' in result
def test_link_code_image_all_in_table(self):
"""Table with code, link, and image cells all render correctly."""
url = 'https://github.com/issues/486'
img_url = 'https://example.com/img.png'
md = (f"| Code | Link | Image |\n"
f"|---|---|---|\n"
f"| `var x = 1` | [#486]({url}) | ![img]({img_url}) |")
result = render_table(md)
assert '<code>var x = 1</code>' in result
assert f'href="{url}"' in result
assert '<img ' in result
# No double-linking
assert result.count('<a ') == 1
def test_image_url_with_query_string_in_table(self):
"""Image URL with & in query string inside table cell — & not mangled."""
url = 'https://example.com/img?w=100&h=200'
md = f"| Image |\n|---|\n| ![sized]({url}) |"
result = render_table(md)
assert f'src="{url}"' in result, (
f"& in image URL must not be escaped. Got: {result}"
)
def test_image_adjacent_to_code_no_interference(self):
"""Image immediately followed by code span in same cell — no token cross-talk."""
t = '![x](https://x.com/x.png) `code`'
result = inline_md(t)
assert '<img ' in result
assert '<code>code</code>' in result
def test_image_inside_code_span_not_rendered(self):
"""An image syntax inside a backtick span must NOT render as an img tag."""
t = '`![not an image](https://example.com/img.png)`'
result = inline_md(t)
# The whole thing is inside backticks — should be literal code, not an img
assert '<img ' not in result, (
f"Image syntax inside code span must not render as <img>. Got: {result}"
)
# Should render as a code element with the raw text inside
assert '<code>' in result