Skip to content

fix(headings): keep an image's alt text in the heading anchor - #333

Open
harsh-thakkar7 wants to merge 1 commit into
petertzy:mainfrom
harsh-thakkar7:fix/heading-image-alt-text-anchor
Open

harsh-thakkar7 wants to merge 1 commit into
petertzy:mainfrom
harsh-thakkar7:fix/heading-image-alt-text-anchor

Conversation

@harsh-thakkar7

Copy link
Copy Markdown
Contributor

Summary

A heading containing an image had no anchor at all, was blank in the document outline, and was silently omitted from the AI table of contents.

Root cause

backend/heading_anchor.py documents what extract_heading_text is supposed to do:

…and links/images ([text](url) keep their visible text)

The regex had a capture group on the link branch but not on the image branch:

_INLINE_MARKUP_RE = re.compile(
    r"\*{1,2}|_{1,2}|`|~~|!\[.*?\]\(.*?\)|\[([^\]]*)\]\(.*?\)"
)

The replacement is sub(r"\1", …), and the image branch matches no group, so ![alt](url) was deleted whole — alt text included.

Effect

For ## ![Architecture diagram](arch.png), on main:

value
outline entry {text: '', anchor: ''}
AI table of contents heading omitted entirely
rendered preview <h2> with no id attribute

So the section existed in the document, showed up in the outline as an empty row, could not be linked to from anywhere, and vanished from the generated TOC.

Headings that merely contained an image were also wrong, just less obviously: ## ![Architecture diagram](arch.png) Results became #results, silently discarding "Architecture diagram" even though that text is visible in the preview.

The fix has two halves, and both are required

1. backend/heading_anchor.py — keep the alt text

The image branch captures its label and the substitution keeps both branches' groups, so an image contributes its text exactly the way a link already did:

r"\*{1,2}|_{1,2}|`|~~|!\[([^\]]*)\]\(.*?\)|\[([^\]]*)\]\(.*?\)"
...
plain = html_unescape(_INLINE_MARKUP_RE.sub(r"\1\2", raw_text or ""))

2. backend/renderer.py — and this part is not optional

The preview slug is built from the rendered heading's text nodes, and an <img> has none. Fixing only part 1 would have produced #architecture-diagram-results in the outline and TOC while the preview still rendered id="results" — a silent regression that breaks exactly the links this module exists to keep working.

assign_heading_ids is documented to emit ids that "exactly match the anchors produced by backend.heading_anchor", so _HeadingIdAssigner now counts an image's alt while a heading is open. It is wired into both handle_starttag and handle_startendtag, because markdown2 emits <img … /> (self-closing) and only the self-closing handler would otherwise ever run. html.parser hands the alt attribute over already decoded, so it is used as-is — that is precisely what the preview displays.

Result

## ![Architecture diagram](arch.png), ## ![…](…) Results and ## Results ![chart](c.png) now all agree across the three anchor producers:

outline TOC preview id
## ![Architecture diagram](arch.png) architecture-diagram architecture-diagram architecture-diagram
## ![Architecture diagram](arch.png) Results architecture-diagram-results architecture-diagram-results architecture-diagram-results
## Results ![chart](c.png) results-chart results-chart results-chart

Duplicate image headings still disambiguate GitHub-style (diagram, diagram-1, diagram-2) in all three.

Testing

12 new tests in tests/test_markdown_outline.py.

The one that matters most asserts the invariant directly rather than case by case:

def test_outline_toc_and_preview_agree_on_every_anchor():
    outline = _outline_anchors(document)
    assert outline == _rendered_ids(document) == _toc_anchors(document)

over a document covering image-only headings, trailing images, bold/italic, inline code, links, a strikethrough inside a link label, Unicode, duplicates and nesting levels. A disagreement between the outline, the TOC and the preview is precisely what makes a table-of-contents link silently stop resolving, so the property is what is pinned.

The rest: alt text preserved as an outline label, image-only heading anchored in all three, image heading listed in the TOC, alt text participating in a mixed heading, several images joined with the rest of the text, an & in alt text, duplicate image headings disambiguated, and headings without images unchanged.

Three feed assign_heading_ids HTML directly to cover what markdown2 cannot produce: an <img> written without the self-closing slash (otherwise the handle_starttag path is dead code as far as the suite is concerned), a non-<img> element carrying an alt attribute proving only images are consulted, and four non-image headings confirming nothing else moved.

8 of 8 mutants killed: the image alt dropped again, the renderer not counting alt at all, alt prepended instead of appended, heading text cleared after reading alt, only the self-closing path handled, alt read from any tag, the link label dropped as collateral, and the replacement reduced to a single group.

8 of the new tests fail against the previous implementation.

I did not assert one case on purpose: ## [![alt](i.png)](url). The link branch matches [Report ![cover as the label, so the nested form is mis-parsed — and it is mis-parsed identically before and after this change, because the defect is in the link branch's non-greedy [^\]]*, not in the image branch. Fixing it means recursing over nested inline markup, which is a larger and riskier change than this one; flagging it rather than quietly widening the diff.

Verification

  • pytest tests/test_markdown_outline.py tests/test_markdown_toc.py — 35 passed
  • pytest tests — unchanged failures, +12 passing
  • ruff check . — clean
  • ruff format --check — clean

Tested against the installed python-docx/markdown2 versions via the repo's own locked environment.

`extract_heading_text`'s docstring promises that images keep their visible
text, but its regex had no capture group on the image branch, so
`![alt](url)` was deleted whole. A heading that is only an image then produced
an empty slug, and with it:

- the document outline listed the section as a blank row with an empty anchor,
- the preview rendered the heading with no `id` at all, so it could not be
  linked to at all,
- the AI table of contents silently omitted the section entirely.

`## ![Architecture diagram](arch.png)` became an unanchorable heading.

The image branch now captures its alt text, and the substitution keeps both
branches' groups (`\1\2`), so an image contributes its label exactly the way a
link already did.

That change alone would have desynchronised the anchors: the preview slug is
built from the rendered heading's text nodes, and an `<img>` has none, so
`_HeadingIdAssigner` would have gone on dropping the alt text while the outline
and table of contents started using it. `_HeadingIdAssigner` now counts an
image's `alt` while inside a heading, in both the start-tag and self-closing
paths, which restores the agreement `assign_heading_ids` is documented to keep.

Adds 12 tests. The central one asserts the invariant directly — the anchors
produced by the outline, the AI table of contents and the rendered preview are
equal for a document covering images, bold, code, links, strikethrough, Unicode
and duplicates — because a disagreement between the three is what makes a
table-of-contents link silently stop resolving. 8 of 8 mutants killed; 8 of the
new tests fail against the previous implementation.

Nested markup such as `[![alt](i.png)](url)` is mis-parsed by the link branch,
identically before and after this change, and is left alone here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant