Доступность (a11y) Текстовые альтернативы: alt, сложная графика, SVG и таблицы данных
0%

Текстовые альтернативы: alt, сложная графика, SVG и таблицы данных

Текстовые альтернативы: alt, сложная графика, SVG и таблицы данных

До сих пор трек был про устройство интерфейса: семантику, ARIA, фокус, визуальные параметры. Эта статья — про его содержание: про всё, что вы нарисовали, а не написали. Картинка, иконка, график выручки, схема архитектуры, скриншот ошибки, таблица тарифов, эмодзи в конце сообщения. Для человека, который получает страницу голосом или на брайлевской строке, всё перечисленное существует ровно настолько, насколько вы его описали словами.

Это самый массовый и самый недооценённый пласт доступности. По данным WebAIM Million, пропущенный alt из года в год держится в тройке самых частых нарушений и встречается больше чем на половине проверенных главных страниц. И это тот редкий случай, когда причина не в незнании API: атрибут alt знают все. Причина в том, что атрибут ставит разработчик, а текст пишет человек, который понимает смысл картинки — и в большинстве команд между этими двумя ролями нет никакого процесса.

Отсюда деление всей статьи надвое. Первая половина — правила и код: какой альтернативой закрывается какой случай. Вторая — то, чего не закроет ни один линтер: как написать текст, который действительно заменяет изображение, что делать со сложной графикой и как разметить таблицу так, чтобы её можно было прочитать по одной ячейке.

Что такое текстовая альтернатива

Критерий WCAG 1.1.1 Non-text Content (уровень A) формулируется так: у всего нетекстового контента есть текстовая альтернатива, служащая той же цели. Ключевые слова — «той же цели», а не «описывающая внешний вид».

Текстовая альтернатива — это не подпись к картинке и не описание пикселей. Это то, что вы сказали бы вслух вместо изображения, если бы описывали страницу по телефону.

Разница видна на примере. Фотография человека в каске на странице вакансии: «мужчина в оранжевой каске смотрит в камеру» — описание пикселей, бесполезное. «Мастер участка, вакансия открыта в Казани» — альтернатива, служащая цели. А если фотография стоит в галерее «наши сотрудники», правильным будет третье: имя и должность.

Текстовая альтернатива уходит сразу в несколько каналов, и это стоит держать в голове — она не «для слепых»:

  • синтезатор речи проговаривает её вслух;
  • брайлевская строка выводит её тактильно — здесь особенно больно от длинных текстов, строка показывает 40 символов за раз;
  • браузер с отключёнными изображениями (медленная сеть, экономия трафика, корпоративный прокси) показывает её на месте картинки;
  • поисковый робот индексирует её; машинный перевод переводит её, в отличие от текста внутри картинки;
  • голосовое управление ищет по ней элемент, когда человек говорит «нажми Корзина».

У 1.1.1 есть закрытый список исключений, и его полезно знать дословно, потому что половина споров на ревью — про них: элементы управления и поля ввода (им нужно имя, а не описание), медиа с временной осью (для него отдельные критерии — см. следующую статью про мультимедиа), тесты, которые потеряют смысл в текстовом виде, сенсорный опыт (произведение искусства), CAPTCHA (описываем назначение, а не содержание) и чистая декорация, которую нужно скрыть от ассистивных технологий.

Дерево решений: какой alt нужен этой картинке

Практически весь alt укладывается в одно дерево. Его стоит один раз выучить и дальше применять механически.

Главная развилка — первая, и она проверяется мысленным экспериментом: закройте картинку ладонью. Страница потеряла смысл? Если нет — это декорация, и её нужно убрать из дерева доступности, а не описывать. Молчание здесь ценнее, чем текст: пятнадцать декоративных завитушек, каждая с честным описанием, превращают страницу в непроходимую.

Между «нет атрибута» и 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: побеждает первый непустой.

Практический вывод номер один: aria-label на картинке перебивает alt, поэтому одновременно их держать бессмысленно и опасно — при рефакторинге останется рассинхрон. Вывод номер два: alt="" не просто даёт пустое имя, а выключает элемент целиком; поэтому пустой alt на единственной картинке внутри ссылки создаёт ссылку без имени — одно из самых злых нарушений, потому что в списке ссылок она выглядит как пустая строка.

Шесть правил хорошего alt

  1. Функция, а не пиксели. Спрашивайте не «что нарисовано», а «зачем это здесь». Стрелка вправо в кнопке — не «стрелка», а «Следующий шаг».
  2. Контекст решает. Одна и та же фотография дома получает разный alt в новости о пожаре («Сгоревшая крыша дома на Лесной»), в объявлении о продаже («Двухэтажный дом, кирпич, 2001 год постройки») и в галерее архитектора («Проект «Лесной», реализован в 2001 году»). Alt не бывает «правильным» в отрыве от страницы — поэтому его нельзя брать из медиатеки один раз навсегда.
  3. Без «изображение», «картинка», «фото». Роль img уже объявляется скринридером; получается «изображение, изображение графика».
  4. Не дублируйте соседний текст. Если под картинкой есть подпись, alt не должен её повторять — либо alt="", либо дополняющая информация.
  5. Длина. Ориентир WebAIM — примерно 125 символов: это не норма стандарта, а эмпирика, за которой начинаются проблемы (некоторые скринридеры режут длинные строки, брайлевская строка требует прокрутки). Не влезает — значит, изображение сложное, и ему нужно длинное описание, а не гигантский alt.
  6. Пишите законченным предложением с точкой. Точка даёт синтезатору паузу; без неё 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;
  • его нельзя скопировать — а именно это чаще всего нужно от промокода или адреса.

Исключения — логотипы (там форма букв и есть смысл) и случаи, когда конкретное начертание существенно. Скриншоты интерфейса под исключение не попадают, но и запрещать их незачем: скриншот показывает расположение, а текст с него надо продублировать рядом. Особенно это касается скриншотов кода и сообщений об ошибках — их копируют, чтобы вставить в поиск.

Таблицы данных: разметка, которую слышно

Таблица — вторая по массовости форма «нетекстовых» данных. В отличие от картинки, здесь текст на месте, но связи между ячейками существуют только визуально: глаз ведёт линию вверх к заголовку столбца и влево к заголовку строки. Скринридер эту линию не проводит — он читает то, что размечено.

Как заголовки таблицы связываются с ячейкой: scope для простых таблиц, headers и id для многоуровневых

Базовая разметка занимает четыре элемента и закрывает 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: первую можно накрутить за час, вторая отражает реальность.

Проверка за пять минут

  1. Выключите изображения (Chrome: Settings → Site settings → Images → Don't allow, или расширение Web Developer). Страница осталась понятной? Все ли ссылки и кнопки читаются?
  2. Пройдите по картинкам скринридером — в NVDA список элементов Insert + F7 включает графику. Ищите имена файлов, слова «изображение», пустые имена внутри ссылок.
  3. Прочитайте вслух абзац с картинкой, подставляя alt. Складывается связный текст — норма.
  4. Сложные изображения: у каждого графика и схемы найдите длинное описание. Не нашли за десять секунд — его не найдёт и пользователь.
  5. Таблицы: Ctrl + Alt + стрелки в NVDA. Заголовки объявляются при переходе между ячейками? На мобильной ширине таблица осталась таблицей?
  6. Масштаб 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-labelledbyaria-labelalttitle, поэтому дублировать источники нельзя.
  • Длинное описание тем полезнее, чем оно видимее: текст в потоке и <details> лучше, чем aria-describedby; longdesc мёртв. Лучшая альтернатива графику — таблица с теми же данными.
  • Инлайновый SVG требует явного режима: aria-hidden для декора, role="img" с именем для смысловых схем. Иконочные шрифты стоит заменить на SVG.
  • Таблица данных без <caption>, <th> и scope — это сетка текста: связь ячейки с заголовком существует только визуально. Сложную шапку размечают через headers/id, но лучше разбить её на две простые таблицы.
  • Автоматика ловит форму (нет атрибута, имя файла, длина), смысл — нет. Нейросеть даёт черновик; метрика зрелости — доля изображений с подтверждённым человеком описанием.

Источники

Что дальше

Мы закрыли статичный нетекстовый контент. Осталась его самая дорогая разновидность — та, у которой есть время: видео, аудио и трансляции. Дальше — мультимедиа: субтитры, транскрипты, аудиодескрипция и доступный плеер.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов