Текстовые альтернативы: alt, сложная графика, SVG и таблицы данных
До сих пор трек был про устройство интерфейса: семантику, ARIA, фокус, визуальные параметры. Эта статья — про его содержание: про всё, что вы нарисовали, а не написали. Картинка, иконка, график выручки, схема архитектуры, скриншот ошибки, таблица тарифов, эмодзи в конце сообщения. Для человека, который получает страницу голосом или на брайлевской строке, всё перечисленное существует ровно настолько, насколько вы его описали словами.
Это самый массовый и самый недооценённый пласт доступности. По данным WebAIM Million, пропущенный alt из года в год держится в тройке самых частых нарушений и встречается больше чем на половине проверенных главных страниц. И это тот редкий случай, когда причина не в незнании API: атрибут alt знают все. Причина в том, что атрибут ставит разработчик, а текст пишет человек, который понимает смысл картинки — и в большинстве команд между этими двумя ролями нет никакого процесса.
Отсюда деление всей статьи надвое. Первая половина — правила и код: какой альтернативой закрывается какой случай. Вторая — то, чего не закроет ни один линтер: как написать текст, который действительно заменяет изображение, что делать со сложной графикой и как разметить таблицу так, чтобы её можно было прочитать по одной ячейке.
Что такое текстовая альтернатива
Критерий WCAG 1.1.1 Non-text Content (уровень A) формулируется так: у всего нетекстового контента есть текстовая альтернатива, служащая той же цели. Ключевые слова — «той же цели», а не «описывающая внешний вид».
Текстовая альтернатива — это не подпись к картинке и не описание пикселей. Это то, что вы сказали бы вслух вместо изображения, если бы описывали страницу по телефону.
Разница видна на примере. Фотография человека в каске на странице вакансии: «мужчина в оранжевой каске смотрит в камеру» — описание пикселей, бесполезное. «Мастер участка, вакансия открыта в Казани» — альтернатива, служащая цели. А если фотография стоит в галерее «наши сотрудники», правильным будет третье: имя и должность.
Текстовая альтернатива уходит сразу в несколько каналов, и это стоит держать в голове — она не «для слепых»:
- синтезатор речи проговаривает её вслух;
- брайлевская строка выводит её тактильно — здесь особенно больно от длинных текстов, строка показывает 40 символов за раз;
- браузер с отключёнными изображениями (медленная сеть, экономия трафика, корпоративный прокси) показывает её на месте картинки;
- поисковый робот индексирует её; машинный перевод переводит её, в отличие от текста внутри картинки;
- голосовое управление ищет по ней элемент, когда человек говорит «нажми Корзина».
У 1.1.1 есть закрытый список исключений, и его полезно знать дословно, потому что половина споров на ревью — про них: элементы управления и поля ввода (им нужно имя, а не описание), медиа с временной осью (для него отдельные критерии — см. следующую статью про мультимедиа), тесты, которые потеряют смысл в текстовом виде, сенсорный опыт (произведение искусства), CAPTCHA (описываем назначение, а не содержание) и чистая декорация, которую нужно скрыть от ассистивных технологий.
Дерево решений: какой alt нужен этой картинке
Практически весь alt укладывается в одно дерево. Его стоит один раз выучить и дальше применять механически.
что-то потеряется?"} B -- "Нет" --> D["Декоративное:
alt="" или CSS-фон"] B -- "Да" --> C{"Оно внутри ссылки
или кнопки?"} C -- "Да" --> E{"Есть ли рядом
видимый текст ссылки?"} E -- "Есть" --> D E -- "Нет" --> F["Функциональное:
alt = действие или цель,
а не вид картинки"] C -- "Нет" --> G{"Что несёт
изображение?"} G -- "Текст" --> H["alt = ровно этот текст
плюс вопрос: почему не текстом?"] G -- "Один факт
или вывод" --> I["Информативное:
alt = этот факт, до ~125 знаков"] G -- "Данные, схема,
карта" --> J["Сложное: краткий alt
плюс длинное описание рядом"] G -- "Проверка
на человека" --> K["CAPTCHA: alt = назначение,
обязательна альтернатива"] style D fill:#d6ecdf,stroke:#3f8a68,color:#22332b style F fill:#d6ecdf,stroke:#3f8a68,color:#22332b style I fill:#d6ecdf,stroke:#3f8a68,color:#22332b style J fill:#f7e6c8,stroke:#b3862f,color:#3a3122 style H fill:#f7e6c8,stroke:#b3862f,color:#3a3122 style K fill:#f6d5d3,stroke:#c05f5a,color:#3a2422
Главная развилка — первая, и она проверяется мысленным экспериментом: закройте картинку ладонью. Страница потеряла смысл? Если нет — это декорация, и её нужно убрать из дерева доступности, а не описывать. Молчание здесь ценнее, чем текст: пятнадцать декоративных завитушек, каждая с честным описанием, превращают страницу в непроходимую.
Между «нет атрибута» и alt="" пропасть, которую важно понять раз и навсегда:
| Разметка | Что в дереве доступности | Что услышит человек |
|---|---|---|
<img src="a.png"> |
элемент с ролью img без имени |
имя файла по буквам: «а точка пэ эн джи» |
<img src="a.png" alt=""> |
элемент удалён из дерева | ничего — и это правильно |
<img src="a.png" alt="Схема оплаты"> |
img с именем |
«Схема оплаты, изображение» |
Пустой alt — это осознанное решение, зафиксированное в коде. Отсутствие атрибута — это «забыли», и ассистивная технология вынуждена гадать, обычно предъявляя человеку IMG_20240712_final_v3.png.
Каталог случаев с кодом
<!-- 1. ДЕКОРАТИВНАЯ. Разделитель, паттерн, стоковая иллюстрация «для настроения» -->
<img src="/divider.svg" alt="">
<!-- Ещё лучше: если картинка чисто оформительская, ей место в CSS, а не в разметке -->
<!-- 2. ИНФОРМАТИВНАЯ. Alt = факт, который картинка сообщает -->
<img src="/chart-mobile.png"
alt="Доля мобильного трафика выросла с 34% до 61% за два года">
<!-- 3. ФУНКЦИОНАЛЬНАЯ. Внутри ссылки или кнопки alt заменяет ТЕКСТ УПРАВЛЕНИЯ -->
<a href="/"><img src="/logo.svg" alt="Тарифы Онлайн, на главную"></a>
<button type="submit"><img src="/search.svg" alt="Найти"></button>
<!-- Плохо: alt="Логотип компании" — человек не понимает, куда ведёт ссылка -->
<!-- 4. КАРТИНКА РЯДОМ С ТЕКСТОМ ССЫЛКИ. Дублировать нельзя -->
<a href="/pricing">
<img src="/icon-price.svg" alt=""> <!-- иначе: «Тарифы, ссылка, Тарифы» -->
Тарифы
</a>
<!-- 5. ИЗОБРАЖЕНИЕ ТЕКСТА. Alt повторяет текст дословно, но лучше не делать так вовсе -->
<img src="/promo-text.png" alt="Скидка 20% до 31 августа">
<!-- 6. CAPTCHA. Описываем назначение, содержание описывать нельзя по определению -->
<img src="/captcha.png" alt="Введите символы с картинки — проверка, что вы не робот">
<button type="button">Прослушать код вместо картинки</button>
<!-- 7. ГРУППА КАРТИНОК, ПЕРЕДАЮЩАЯ ОДНУ МЫСЛЬ: описывает одна, остальные молчат -->
<p>
<img src="/star-full.svg" alt="Рейтинг 4 из 5">
<img src="/star-full.svg" alt=""><img src="/star-full.svg" alt="">
<img src="/star-full.svg" alt=""><img src="/star-empty.svg" alt="">
</p>
<!-- 8. ЭМОДЗИ. В тексте читается синтезатором сам по себе; как самостоятельный
элемент управления ему нужно имя -->
<button aria-label="Понравилось"><span aria-hidden="true">👍</span></button>
<!-- 9. ФОН СРЕДСТВАМИ CSS. Если фон несёт смысл — это баг разметки -->
<div class="hero" role="img" aria-label="Команда на складе сортирует заказы"></div>
Отдельно — про иконочные шрифты. Символ из Font Awesome или самописного шрифта на самом деле является буквой в приватной области Unicode. Скринридер читает букву: человек слышит «частное использование U+F007» или, в худшем случае, случайный символ. Лечение: aria-hidden="true" на элементе-иконке и текстовое имя у контрола. Стратегически — переезд на SVG: иконочные шрифты ломаются ещё и при отключении веб-шрифтов, и в режиме принудительных цветов (см. визуальную доступность).
Ещё два антипаттерна, которые встречаются постоянно. title вместо alt: атрибут title показывается тултипом по наведению мышью, не показывается при фокусе с клавиатуры, не показывается на тачскрине и читается скринридерами непоследовательно. Это не альтернатива, а в лучшем случае дополнение. И alt как место для ключевых слов: «купить окна недорого Москва окна ПВХ» вместо описания — прямой вред и поисковой выдаче, и человеку.
Как вычисляется имя изображения
У img несколько источников имени, и они конкурируют. Порядок задан спецификацией HTML-AAM и разобран в ACCNAME: побеждает первый непустой.
указывает на текст?"} B -- "да" --> W["Имя = этот текст"] B -- "нет" --> C{"aria-label
непустой?"} C -- "да" --> W C -- "нет" --> D{"alt задан?"} D -- "да, непустой" --> W D -- "да, пустой" --> P["Элемент скрыт
из дерева"] D -- "нет" --> E{"title задан?"} E -- "да" --> W E -- "нет" --> F["Имени нет:
браузер или скринридер
подставит имя файла"] style W fill:#d6ecdf,stroke:#3f8a68,color:#22332b style P fill:#d6ecdf,stroke:#3f8a68,color:#22332b style F fill:#f6d5d3,stroke:#c05f5a,color:#3a2422
Практический вывод номер один: aria-label на картинке перебивает alt, поэтому одновременно их держать бессмысленно и опасно — при рефакторинге останется рассинхрон. Вывод номер два: alt="" не просто даёт пустое имя, а выключает элемент целиком; поэтому пустой alt на единственной картинке внутри ссылки создаёт ссылку без имени — одно из самых злых нарушений, потому что в списке ссылок она выглядит как пустая строка.
Шесть правил хорошего alt
- Функция, а не пиксели. Спрашивайте не «что нарисовано», а «зачем это здесь». Стрелка вправо в кнопке — не «стрелка», а «Следующий шаг».
- Контекст решает. Одна и та же фотография дома получает разный
altв новости о пожаре («Сгоревшая крыша дома на Лесной»), в объявлении о продаже («Двухэтажный дом, кирпич, 2001 год постройки») и в галерее архитектора («Проект «Лесной», реализован в 2001 году»). Alt не бывает «правильным» в отрыве от страницы — поэтому его нельзя брать из медиатеки один раз навсегда. - Без «изображение», «картинка», «фото». Роль
imgуже объявляется скринридером; получается «изображение, изображение графика». - Не дублируйте соседний текст. Если под картинкой есть подпись,
altне должен её повторять — либоalt="", либо дополняющая информация. - Длина. Ориентир WebAIM — примерно 125 символов: это не норма стандарта, а эмпирика, за которой начинаются проблемы (некоторые скринридеры режут длинные строки, брайлевская строка требует прокрутки). Не влезает — значит, изображение сложное, и ему нужно длинное описание, а не гигантский
alt. - Пишите законченным предложением с точкой. Точка даёт синтезатору паузу; без неё alt сливается со следующим элементом в одну фразу.
Мини-тест на качество, который работает лучше любого чеклиста: прочитайте страницу вслух коллеге, подставляя alt вместо картинок. Все места, где собеседник переспросил, — дефекты.
figure, figcaption и подписи
<figure> — это блок «контент плюс подпись», где подпись видна всем. Она не заменяет alt, потому что решает другую задачу: подпись объясняет контекст («Рис. 3. Схема репликации»), а альтернатива передаёт содержание.
<figure>
<img src="/replication.png"
alt="Первичный узел пишет журнал, две реплики читают его и применяют изменения последовательно">
<figcaption>Рис. 3. Асинхронная репликация: реплика отстаёт на время доставки журнала</figcaption>
</figure>
Тонкость: у элемента figure доступным именем становится текст figcaption. Значит, если написать одинаковый текст в alt и в figcaption, человек услышит его дважды подряд. Рабочая схема — alt про содержание, figcaption про интерпретацию; если содержание полностью раскрыто подписью, alt можно оставить пустым.
Сложные изображения: график, схема, карта
Сложное изображение — то, где смысл не сводится к одной фразе: график с несколькими рядами, архитектурная схема, карта, инфографика, организационная диаграмма. Стандартный приём — два уровня: короткий alt отвечает «что это», длинное описание отвечает «что в нём».
Четыре способа дать длинное описание, по убыванию надёжности:
| Способ | Разметка | Плюсы и минусы |
|---|---|---|
| Текст рядом в потоке | обычный абзац после figure |
самый надёжный: доступен всем, включая зрячих; занимает место на странице |
| Раскрывающийся блок | <details><summary>Описание графика</summary>… |
компактно, работает без JS, находится с клавиатуры |
| Ссылка на отдельную страницу | <a href="/charts/q4-description"> |
годится для очень длинных описаний и карт |
aria-describedby |
указывает на скрытый или видимый элемент | описание слышит только скринридер; зрячий человек с дислексией — нет |
longdesc |
атрибут из HTML4 | не используйте: объявлен устаревшим, поддержки практически нет |
Обратите внимание на порядок: способ «описание видно всем» стоит выше способа «описание только для скринридера». Это общий принцип трека — видимый текст всегда сильнее скрытого, потому что им пользуется больше людей и он не протухает незаметно.
Что писать в описании графика. Работающий шаблон из четырёх частей: (1) тип и предмет — «столбчатая диаграмма, выручка по кварталам 2024 года»; (2) оси и диапазоны — «по горизонтали кварталы, по вертикали миллионы рублей от 0 до 50»; (3) значения или тренд — сами числа, если их немного, иначе форма кривой, экстремумы и выбросы; (4) вывод, ради которого график поставили. Четвёртый пункт часто и есть настоящий alt: если график иллюстрирует тезис «продажи выросли втрое», то это и нужно сказать.
Лучшая альтернатива графику — таблица. Она не «запасной вариант для слепых», а полноценный источник: по ней можно посчитать, скопировать в таблицу, проверить ваш вывод. Отсюда рабочий паттерн: график для глаз, таблица под раскрывающимся блоком для всех остальных.
<figure>
<img src="/revenue-2024.svg" alt="Выручка по кварталам 2024 года: рост с 12 до 41 млн рублей">
<figcaption>Выручка, млн рублей</figcaption>
<details>
<summary>Данные графика таблицей</summary>
<table>
<caption>Выручка по кварталам 2024 года, млн рублей</caption>
<thead>
<tr><th scope="col">Квартал</th><th scope="col">Выручка</th><th scope="col">Прирост</th></tr>
</thead>
<tbody>
<tr><th scope="row">I</th><td>12,4</td><td>—</td></tr>
<tr><th scope="row">II</th><td>19,1</td><td>+54%</td></tr>
<tr><th scope="row">III</th><td>28,7</td><td>+50%</td></tr>
<tr><th scope="row">IV</th><td>41,3</td><td>+44%</td></tr>
</tbody>
</table>
</details>
</figure>
Карты. Интерактивная карта с точками магазинов бесполезна голосом в принципе. Альтернатива — не описание карты, а список адресов с телефонами и часами работы, доступный из того же места. Это же правило спасает мобильных пользователей на плохой сети. Схемы и диаграммы из документации разбираются похоже — подробнее про их подготовку в статье о диаграммах в технической документации, а про сам выбор визуализации — в треке аналитики.
Не все изображения одинаково выгодно описывать. Полезно один раз расставить приоритеты по двум осям: сколько теряет человек без описания и сколько стоит описание сделать.
Квадрант «дорого описать и без описания непонятно» — сигнал не героически писать текст, а сменить формат: карту заменить списком, схему — структурированным текстом, инфографику — таблицей с абзацем вывода.
SVG: четыре режима и их разметка
SVG — не картинка, а поддерево DOM, поэтому у него больше способов сломаться и больше способов сделать правильно.
| Режим | Разметка | Что услышит человек |
|---|---|---|
| Внешний файл | <img src="i.svg" alt="…"> |
обычная картинка, содержимое SVG в дерево не попадает |
| Инлайн, декоративный | <svg aria-hidden="true" focusable="false"> |
ничего |
| Инлайн, информативный | <svg role="img" aria-label="…"> |
имя как у изображения |
| Инлайн, интерактивный | элементы внутри с ролями, именами и tabindex |
работает как виджет |
<!-- Иконка внутри кнопки: молчит, имя даёт текст кнопки -->
<button type="button">
<svg width="16" height="16" aria-hidden="true" focusable="false"><use href="#i-trash"/></svg>
Удалить
</button>
<!-- Самостоятельная схема: role="img" обязателен, иначе Safari прочитает
содержимое поэлементно, а часть скринридеров не увидит <title> вовсе -->
<svg role="img" aria-labelledby="dg-title dg-desc" viewBox="0 0 400 200">
<title id="dg-title">Схема доставки заказа</title>
<desc id="dg-desc">Заказ проходит склад, сортировочный центр и курьера; на каждом шаге меняется статус.</desc>
<!-- графика -->
</svg>
Три практические детали. Первая: role="img" на инлайновом SVG нужен всегда, когда он информативный, — без него браузеры расходятся в том, считать ли SVG группой или изображением. Вторая: focusable="false" — страховка от старых движков, где SVG попадал в порядок обхода Tab, создавая «пустые» остановки. Третья: <title> внутри SVG показывается как тултип при наведении мышью, что иногда воспринимают как баг вёрстки — если тултип не нужен, используйте aria-label.
Отдельная ловушка спрайтов: при вставке одного и того же инлайнового SVG дважды на странице дублируются id, и aria-labelledby начинает указывать на первый попавшийся. Генерируйте уникальные идентификаторы (в React — хук useId) либо давайте имя через aria-label.
И приятное следствие: SVG с fill="currentColor" наследует цвет текста, а значит корректно перекрашивается в тёмной теме и в режиме принудительных цветов, чего растровая иконка не умеет.
Изображение текста
Критерий 1.4.5 Images of Text (AA) требует: если тот же визуальный эффект достижим настоящим текстом — используйте текст. Причины сугубо практические, и ни одна из них не про скринридеры:
- текст в картинке не масштабируется — при увеличении он размывается, тогда как настоящий текст остаётся резким;
- он не перекрашивается в тёмной теме и в режиме принудительных цветов: белая плашка остаётся белой на чёрном фоне;
- его не переводит автопереводчик и не находит
Ctrl + F; - его нельзя скопировать — а именно это чаще всего нужно от промокода или адреса.
Исключения — логотипы (там форма букв и есть смысл) и случаи, когда конкретное начертание существенно. Скриншоты интерфейса под исключение не попадают, но и запрещать их незачем: скриншот показывает расположение, а текст с него надо продублировать рядом. Особенно это касается скриншотов кода и сообщений об ошибках — их копируют, чтобы вставить в поиск.
Таблицы данных: разметка, которую слышно
Таблица — вторая по массовости форма «нетекстовых» данных. В отличие от картинки, здесь текст на месте, но связи между ячейками существуют только визуально: глаз ведёт линию вверх к заголовку столбца и влево к заголовку строки. Скринридер эту линию не проводит — он читает то, что размечено.
Базовая разметка занимает четыре элемента и закрывает 90% случаев:
<table>
<caption>Тарифы доставки, август 2026</caption>
<thead>
<tr>
<th scope="col">Город</th>
<th scope="col">Срок, дней</th>
<th scope="col">Цена, руб.</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Казань</th><td>2</td><td>350</td></tr>
<tr><th scope="row">Омск</th><td>4</td><td>420</td></tr>
</tbody>
</table>
<!-- На ячейке «420» человек услышит «Омск, Цена руб., 420».
Без th и scope — только «420», то есть ничего. -->
<caption>— имя таблицы. Оно попадает в список таблиц, по которому скринридер навигирует, поэтому «Таблица 1» бесполезно, а «Тарифы доставки, август 2026» — нет.scope="col"иscope="row"— направление действия заголовка. Есть ещёcolgroupиrowgroupдля заголовков, накрывающих группу.- Единицы измерения — в заголовок, а не в каждую ячейку: «Цена, руб.» и
420вместо «Цена» и «420 руб.» в сорока строках подряд. - Пустых ячеек лучше избегать: NVDA произносит «пусто», и человек не понимает, ноль это, «нет данных» или ошибка вёрстки. Ставьте прочерк и поясняйте его в
captionили сноске.
Для таблиц с многоуровневой шапкой scope уже не хватает — нужна явная адресация через headers и id:
<table>
<caption>Продажи по регионам и каналам, млн руб.</caption>
<tr>
<td rowspan="2"></td>
<th id="h-nord" colspan="2" scope="colgroup">Север</th>
<th id="h-south" colspan="2" scope="colgroup">Юг</th>
</tr>
<tr>
<th id="h-n-on">Онлайн</th><th id="h-n-off">Розница</th>
<th id="h-s-on">Онлайн</th><th id="h-s-off">Розница</th>
</tr>
<tr>
<th id="h-q1" scope="row">I квартал</th>
<td headers="h-q1 h-nord h-n-on">12,4</td>
<td headers="h-q1 h-nord h-n-off">8,1</td>
<td headers="h-q1 h-south h-s-on">15,0</td>
<td headers="h-q1 h-south h-s-off">6,3</td>
</tr>
</table>
<!-- «I квартал, Север, Онлайн, 12,4» — ровно то, что видит глаз -->
Разметка громоздкая, поэтому первый вопрос к сложной таблице всегда один: нельзя ли разбить её на две простые? Две таблицы «Север» и «Юг» с обычным scope читаются лучше и пишутся быстрее — и это верно не только для скринридера.
Сортировка. Заголовок-кнопка плюс атрибут aria-sort со значением ascending, descending или none; одновременно отсортированный столбец только один, остальные aria-sort не имеют вовсе.
<th scope="col" aria-sort="descending">
<button type="button">Цена</button>
</th>
Прокручиваемая таблица. Широкую таблицу заворачивают в контейнер с overflow-x: auto — и получают область, которую нельзя прокрутить с клавиатуры. Лечение — сделать контейнер фокусируемым и назвать его:
<div role="region" aria-labelledby="cap-tariffs" tabindex="0" class="table-scroll">
<table>
<caption id="cap-tariffs">Тарифы доставки, август 2026</caption>
…
</table>
</div>
Адаптивные таблицы — главная ловушка. Популярный приём «на мобильном превращаем строки в карточки» делается через display: block на tr и td. Это не косметика: смена display уничтожает роли таблицы в дереве доступности, и вместо таблицы человек получает поток текста без заголовков. Варианты: оставить горизонтальную прокрутку (см. выше), либо вернуть роли явно (role="table", role="row", role="cell" на изменённых элементах), либо честно отдать на узком экране другой контент — список карточек, который таблицей никогда и не притворяется.
И последнее: role="grid" — не «таблица покрасивее». Это интерактивный виджет со своей клавиатурной моделью (стрелки перемещают фокус между ячейками, а не читают их), уместный для редактируемых сеток и календарей. Для данных, которые только читают, правильная роль — нативная table. Разбор клавиатурных моделей виджетов — в статье про компоненты.
Скрипт аудита изображений
Автоматика не напишет alt, но отлично находит места, где его нет или где он заведомо мусорный. Такой скрипт живёт в CI рядом с проверками из статьи про тестирование и процесс.
"""Аудит текстовых альтернатив в HTML: классификация img и поиск подозрительных alt."""
import re
from dataclasses import dataclass
from html.parser import HTMLParser
BAD_PREFIXES = ("изображение", "картинка", "фото ", "рисунок", "image of", "picture of")
FILENAME_RE = re.compile(r"\.(png|jpe?g|gif|svg|webp)$|^(img|dsc|screenshot)[-_\d]", re.I)
ALT_SOFT_LIMIT = 125 # эмпирика WebAIM, а не норма стандарта
NAMED_TAGS = ("a", "button")
@dataclass(frozen=True)
class Issue:
line: int
level: str # error | warning
message: str
@dataclass
class Frame:
"""Кадр стека для <a> или <button>: копим признаки доступного имени."""
tag: str
has_name: bool # aria-label на самом контроле или непустой alt внутри
has_text: bool = False # видимый текст внутри
class AltAuditor(HTMLParser):
"""Однопроходный разбор документа: для каждого img решаем, какой это случай."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.issues: list[Issue] = []
self._stack: list[Frame] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
a = {k: (v or "") for k, v in attrs}
if tag in NAMED_TAGS:
self._stack.append(Frame(tag, has_name=bool(a.get("aria-label"))))
elif tag == "img":
self._check_img(a)
def handle_endtag(self, tag: str) -> None:
if tag in NAMED_TAGS and self._stack:
frame = self._stack.pop()
if not (frame.has_name or frame.has_text):
self._add("error", f"<{frame.tag}> без доступного имени")
def handle_data(self, data: str) -> None:
if self._stack and data.strip():
self._stack[-1].has_text = True
def _check_img(self, a: dict[str, str]) -> None:
alt, src = a.get("alt"), a.get("src", "")
if a.get("aria-label") and alt:
self._add("warning", f"aria-label перебивает alt у {src}")
if alt is None: # атрибута нет вовсе
self._add("error", f"нет атрибута alt у {src}")
return
if not alt: # осознанная декорация
return
if self._stack: # картинка дала имя ссылке
self._stack[-1].has_name = True
low = alt.strip().lower()
if FILENAME_RE.search(low):
self._add("error", f"alt похож на имя файла: {alt!r}")
if low.startswith(BAD_PREFIXES):
self._add("warning", f"alt начинается со слова-паразита: {alt!r}")
if len(alt) > ALT_SOFT_LIMIT:
self._add("warning", f"alt длиной {len(alt)} знаков — нужно длинное описание")
def _add(self, level: str, message: str) -> None:
self.issues.append(Issue(self.getpos()[0], level, message))
def audit(html: str) -> list[Issue]:
parser = AltAuditor()
parser.feed(html)
parser.close()
return parser.issues
if __name__ == "__main__":
sample = """
<a href="/"><img src="logo.svg" alt=""></a>
<img src="chart.png">
<img src="p.png" alt="Изображение графика IMG_2024.png">
"""
for issue in audit(sample):
print(f"{issue.line}: [{issue.level}] {issue.message}")
Сложность — O(n) по длине документа (один проход парсера) и O(k) по памяти, где k — число найденных проблем; стек контролов добавляет O(d) по глубине вложенности. На типичной странице это единицы миллисекунд, поэтому проверку не жалко ставить на каждый коммит. Важно понимать её потолок: скрипт видит форму, а не смысл. alt="Фотография" он пропустит, alt="кнопка" на графике — тоже.
Нейросети и alt: где помогают, где вредят
Автоматическое описание картинок доросло до практической пользы: современные мультимодальные модели уверенно распознают объекты и читают текст на изображении. Из этого не следует, что задача закрыта.
Что модель делает хорошо: черновик для фотобанка, распознавание текста со скриншота, описание в пользовательском контенте, где альтернативы нет вообще (соцсеть, где сто тысяч фотографий в час). Что она делает плохо: не знает контекста страницы — а мы видели, что именно контекст определяет правильный alt; не знает намерения автора — ради какого тезиса поставлен график; уверенно ошибается в числах, именах и надписях мелким шрифтом; не отличает декоративное от информативного и потому склонна описывать всё подряд, засоряя страницу.
Рабочая политика: модель генерирует черновик, человек подтверждает или переписывает, а в интерфейсе CMS поле alt показывает статус «сгенерировано» до подтверждения. Метрика, которую имеет смысл вести, — не «доля картинок с alt», а доля картинок с подтверждённым человеком alt: первую можно накрутить за час, вторая отражает реальность.
Проверка за пять минут
- Выключите изображения (Chrome:
Settings → Site settings → Images → Don't allow, или расширение Web Developer). Страница осталась понятной? Все ли ссылки и кнопки читаются? - Пройдите по картинкам скринридером — в NVDA список элементов
Insert + F7включает графику. Ищите имена файлов, слова «изображение», пустые имена внутри ссылок. - Прочитайте вслух абзац с картинкой, подставляя
alt. Складывается связный текст — норма. - Сложные изображения: у каждого графика и схемы найдите длинное описание. Не нашли за десять секунд — его не найдёт и пользователь.
- Таблицы:
Ctrl + Alt + стрелкив NVDA. Заголовки объявляются при переходе между ячейками? На мобильной ширине таблица осталась таблицей? - Масштаб 400% на странице с картинкой-текстом: текст размылся — нарушение 1.4.5.
Типичные ошибки — сводка
| Симптом | Причина | Лечение |
|---|---|---|
| Скринридер читает имя файла | нет атрибута alt |
добавить alt (пустой для декора) |
| «Ссылка, пусто» в списке ссылок | единственная картинка в ссылке с alt="" |
alt = назначение ссылки |
| Каждый пункт списка читается дважды | alt иконки дублирует текст рядом |
alt="" у иконки |
| «Изображение, изображение схемы» | слово «изображение» внутри alt |
убрать, роль объявляется сама |
| Alt на 400 символов | сложное изображение втиснуто в атрибут | краткий alt плюс длинное описание |
| График «есть», а данных нет | нет альтернативы диаграмме | таблица под details рядом |
| Иконки исчезают в тёмной теме | иконочный шрифт или растр | SVG с fill="currentColor" |
| Таблица читается как поток текста | display: block на tr и td |
прокрутка вместо перестроения либо явные роли |
| «Пусто, пусто, пусто» в таблице | пустые ячейки | прочерк и пояснение в caption |
| Одна и та же схема читается чужим текстом | дублирующиеся id в инлайновом SVG |
уникальные идентификаторы или aria-label |
Мини-итог
- Текстовая альтернатива — замена, а не описание: то, что вы сказали бы вслух вместо изображения. Правильность зависит от контекста страницы, поэтому один
altна всю медиатеку не бывает верным. - Дерево решений: декоративное →
alt=""; функциональное → назначение действия; информативное → факт до ~125 знаков; сложное → краткийaltплюс длинное описание; изображение текста → лучше настоящий текст. - Отсутствие
altиalt=""— противоположные вещи: первое заставляет читать имя файла, второе осознанно убирает элемент из дерева. Имя вычисляется по порядкуaria-labelledby→aria-label→alt→title, поэтому дублировать источники нельзя. - Длинное описание тем полезнее, чем оно видимее: текст в потоке и
<details>лучше, чемaria-describedby;longdescмёртв. Лучшая альтернатива графику — таблица с теми же данными. - Инлайновый SVG требует явного режима:
aria-hiddenдля декора,role="img"с именем для смысловых схем. Иконочные шрифты стоит заменить на SVG. - Таблица данных без
<caption>,<th>иscope— это сетка текста: связь ячейки с заголовком существует только визуально. Сложную шапку размечают черезheaders/id, но лучше разбить её на две простые таблицы. - Автоматика ловит форму (нет атрибута, имя файла, длина), смысл — нет. Нейросеть даёт черновик; метрика зрелости — доля изображений с подтверждённым человеком описанием.
Источники
- WCAG 2.2: 1.1.1 Non-text Content, 1.4.5 Images of Text, 1.3.1 Info and Relationships.
- W3C WAI: Images Tutorial — эталонный разбор всех типов изображений; Tables Tutorial — разметка простых и сложных таблиц; Complex Images.
- HTML-AAM и ACCNAME 1.2 — как именно вычисляется доступное имя.
- WebAIM: Alternative Text и WebAIM Million; MDN: img, MDN: SVG accessibility.
- Смежное на портале: семантика HTML, доступность на этапе дизайна, визуализация данных, базовый свод правил — Гайд по accessibility.
Что дальше
Мы закрыли статичный нетекстовый контент. Осталась его самая дорогая разновидность — та, у которой есть время: видео, аудио и трансляции. Дальше — мультимедиа: субтитры, транскрипты, аудиодескрипция и доступный плеер.