label-content-name-mismatch: punctuation normalization is language-dependent — CJK / full-width punctuation is not removed, causing false positives on Chinese, Japanese and Korean pages
Product
axe-core
Product Version
4.13.0
Latest Version
- I have tested the issue with the latest version of the product
Issue Description
Summary
isStringContained() normalizes both strings with removeUnicode(str, { punctuations: true })
before comparing, so a visible label and an accessible name that differ only in punctuation
are treated as a match.
getPunctuationRegExp() only covers ASCII/Latin punctuation. It does not cover CJK punctuation
(U+3000–U+303F) or the full-width forms (U+FF01–U+FF65) that are used in essentially all
Chinese, Japanese and Korean content.
The result: the same authoring pattern passes in English and fails in Chinese.
Environment
- axe-core 4.13.0 (also reproduced on 4.10.3 — long-standing, not a regression)
- Chromium 145.0.7632.6 (Playwright 1.58.2); also reproducible in Chrome DevTools
- Rule:
label-content-name-mismatch— WCAG 2.5.3 Label in Name, Level A
Steps to reproduce
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>label-content-name-mismatch: CJK punctuation</title></head>
<body>
<!-- English: visible text and accessible name differ only in punctuation -->
<a id="en" href="/a" aria-label="Example Corp, new window">Example Corp (new window)</a>
<!-- Chinese: identical relationship, full-width punctuation -->
<a id="zh" href="/b" aria-label="範例公司官網,另開新視窗">範例公司官網(另開新視窗)</a>
</body>
</html>await axe.run(document, { runOnly: { type: 'rule', values: ['label-content-name-mismatch'] } });Expected
Both pass. In both cases the accessible name contains the visible label; only the punctuation
separating the supplementary phrase differs ((...) / , ... vs (...) / ,...).
Actual
{ "violations": ["#zh"], "passes": ["#en"] }Root cause
curateString() → removeUnicode(str, { emoji: true, nonBmp: true, punctuations: true })
→ getPunctuationRegExp():
function getPunctuationRegExp() {
return /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&\xa3\xa2\xa5\xa7\u20ac()*+,\-.\/:;<=>?@\[\]^_`{|}~\xb1]/g;
}Covers General Punctuation (U+2000–U+206F), Supplemental Punctuation (U+2E00–U+2E7F) and ASCII punctuation. Not covered:
- U+3000–U+303F — CJK Symbols and Punctuation (
、。「」『』()〈〉…) - U+FF01–U+FF65 — Halfwidth and Fullwidth Forms (
!"(),.:;…) - U+FE10–U+FE1F, U+FE30–U+FE4F, U+FE50–U+FE6F — vertical / compatibility / small forms
Verified with the exposed commons API:
const curate = (s) =>
axe.commons.text.sanitize(
axe.commons.text.removeUnicode(s, { emoji: true, nonBmp: true, punctuations: true }));
curate('Example Corp (new window)'); // "Example Corp new window"
curate('Example Corp, new window'); // "Example Corp new window" → contained ✔
curate('範例公司官網(另開新視窗)'); // "範例公司官網(另開新視窗)" ← punctuation kept
curate('範例公司官網,另開新視窗'); // "範例公司官網,另開新視窗" → not contained ✘Divergence from the ACT rule
ACT rule 2ee8b8 Visible label is part of accessible name
— the rule axe-core maps to, and which #5203 tracks aligning with — defines the tokenization
step as:
For each character that either a) represents non-text content, or b) isn’t a letter or a digit: replace that character with a space character. […] Use the Unicode general categories “L” (Letter) and “N” (Number). (This will exclude hyphens, punctuation, emoji, and more.)
Under that definition, (, ) and , are neither L nor N, so both strings tokenize to
[範例公司官網, 另開新視窗] and the example passes. axe-core's punctuation regex is an
approximation of that step whose coverage happens to depend on the language of the content.
Note that the algorithm's parenthesis-removal step is explicitly scoped to U+0028/U+0029, so the full-width parentheses are not removed there — they fall out at the letter/digit step quoted above. The algorithm also applies Unicode case folding and NFKD normalization before tokenizing.
Impact
WCAG 2.5.3 is Level A, so one false positive is enough to fail an automated audit.
The affected pattern — a visible label plus a supplementary phrase in the accessible name, e.g.
(另開新視窗) / (新しいウィンドウで開きます) ("opens in a new window") — is a standard,
widely-taught convention on CJK government and enterprise sites.
This was found while auditing production sites in a CJK locale, not in a synthetic test.
Related
- #5203 — get
label-content-name-mismatchconsistent with ACT2ee8b8(this issue is one concrete instance of the divergence) - #4311 — punctuation handling is too forgiving for hyphens (opposite direction, same code path)
- #2128 (closed) — why the unicode regexes are split between punctuation and nonBmp
- #4678 — invisible text wrongly counted as visible text (same rule, different step)
Possibly also affected
removeUnicode(..., { punctuations: true }) is also used by identicalLinksSamePurposeEvaluate
(identical-links-same-purpose, SC 2.4.9) and by isUnicodeOrPunctuation() /
isHumanInterpretable(). Links differing only by CJK punctuation would not be recognized as
having the same purpose. Not verified in detail.
Suggested fix
ACT-aligned (also resolves #4311, since replacing with a space rather than deleting makes
non-standard / nonstandard tokenize differently):
// replace anything that is not a Letter or a Number with a space, then tokenize
str.replace(/[^\p{L}\p{N}]/gu, ' ')Minimal (keeps current semantics, just closes the language gap):
function getPunctuationRegExp() {
// \p{P} = punctuation, \p{S} = symbols. Both are needed: the current character class
// includes $ + < = > ^ ` | ~ and £ ¢ ¥ ± which are Symbol, not Punctuation.
return /[\p{P}\p{S}]/gu;
}The only characters in the current ranges not covered by [\p{P}\p{S}] are U+2000/U+2001
(whitespace, already collapsed by sanitize()) and U+206F (a format character, already removed
by getCategoryFormatRegExp() in the same removeUnicode() call).
⚠️ Caveat on the minimal option: getPunctuationRegExp() is shared with
isUnicodeOrPunctuation() → isHumanInterpretable(), so widening it is not local to this
comparison. Strings made only of symbols (★, →, ✓) would start curating down to an empty
string, making them "not human interpretable" — which makes this rule return incomplete rather
than pass/fail for such labels. The ACT-aligned option avoids that, because the tokenization would
live in the comparison rather than in the shared helper. Either way the change should be run
against the existing test suite; I have only verified the label/name comparison itself.
⚠️ Either way, full-width letters and digits must not be stripped (U+FF10–U+FF19,
U+FF21–U+FF3A, U+FF41–U+FF5A) — those are meaningful characters, not punctuation.
\p{P} and \p{L}\p{N} both handle this correctly.
Both were checked against the following cases; each behaves as expected with either fix:
| visible label | accessible name | current | with either fix |
|---|---|---|---|
範例公司官網(另開新視窗) |
範例公司官網,另開新視窗 |
✘ violation | ✔ contained |
Example Corp (new window) |
Example Corp, new window |
✔ contained | ✔ contained (unchanged) |
新しいウィンドウで開きます。 |
新しいウィンドウで開きます |
✘ violation | ✔ contained |
全形數字123ABC |
全形數字123ABC |
✔ | ✔ (full-width alphanumerics preserved) |
Happy to open a PR with the fix plus test cases if that would help.
Source: dequelabs/axe-core