dequelabs / dequelabs/axe-core
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
- Dominant language
- JavaScript
- Stars
- 7.5k
- Forks
- 933
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 17
Description
### Product
axe-core
### Product Version
4.13.0
### Latest Version
- [x] 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
```html
label-content-name-mismatch: CJK punctuation
```
```js
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
```json
{ "violations": ["#zh"], "passes": ["#en"] }
```
## Root cause
`curateString()` → `removeUnicode(str, { emoji: true, nonBmp: true, punctuations: true })`
→ `getPunctuationRegExp()`:
```js
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:
```js
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](https://www.w3.org/WAI/standards-guidelines/act/rules/2ee8b8/proposed/)
— 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-mismatch` consistent with ACT `2ee8b8` (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):
```js
// 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):
```js
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.
Contributor guide
Research direction
Locate getPunctuationRegExp(), removeUnicode(), curateString(), and the label-content-name-mismatch rule and its existing tests. Run the relevant test suite, then compare the ACT-aligned and minimal approaches against the CJK examples, shared helper behavior, and full-width letters and digits. Done means the reported CJK cases pass without regressing existing punctuation or human-interpretable checks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- accessibility
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 72/100