Фронтенд-разработка

Вертикальные отступы на ЦСС. С точностью до пикселя

Дизайнеры выравнивают текст по визуальной границе букв. Разработчики — по текстовой площадке ЦСС, которая где-то в полтора раза выше. Я скрафтил инструмент, который убирает лишнее пространство сверху и снизу текста.

В макетах «Индизайна» и «Скетча» отступы считаются от границ букв: снизу — от базовой линии, сверху — от высоты строчных либо заглавных. Другими словами, высота текстового блока равна высоте его букв:

На макете

— Верстаем!

В ЦСС высота текстовых элементов по умолчанию равна высоте строки. Это примерно в полтора раза больше высоты букв:

В жизни

— Верстаем!

При переносе из макета в браузер отступы становятся больше, хотя их числовое значение не меняется:

Желаемое

Дизайнер говорит, что отступ равен 38,4 пк, и фронтендер, что 38,4 пк.

Оба правы, но вместе пока не живут.

Действительное

Дизайнер говорит, что отступ равен 38,4 пк, и фронтендер, что 38,4 пк.

Оба правы, но вместе пока не живут.

В обоих случаях задан вертикальный отступ в 38,4 пк. Текстовый блок по умолчанию содержит расстояние, зарезервированное под выносные элементы в метриках шрифта. В сумме с высотой букв это расстояние образует высоту строки.

Чтобы добиться выравнивания по высоте букв, к тексту нужно применить компенсацию — накинуть отрицательный марджин.

UPD В 133-м Хроме для решения этой задачи появилось ЦСС-свойство text-box-trim. Поддержка пока меньше пяти процентов, но всё равно балдёж:

* {
	/* Обрезает до прописных */
	text-box: trim-both cap alphabetic;

	/* До строчных */
	text-box: trim-both ex alphabetic;
}

Решение: бюро Горбунова

Разработчики бюро Горбунова придумали завернуть каждый текстовый элемент в класс-обёртку. Обёртка декларирует желаемый отступ, а ХТМЛ-элемент внутри обёртки с помощью отрицательных отступов убирает лишнее расстояние:

<div class="textNode">
	<p>Сновидение понимает ролевой филогенез.</p>
</div>

<style>
	.textNode {
		margin-bottom: 36px;
	}

	p {
		font: 'Bureauserif';
		margin-top: -17px;
		margin-bottom: -19px;
	}
</style>

Минус подхода в том, что отступы нужно описать вручную для каждой пары кегля и интерлиньяжа:

.heading-2 {
	font: 400 36px/36px Bureausans;
	margin-bottom: -8px;
	margin-top: -10px;
}

.caption {
	font: 100 16px/18px Bureausans;
	margin-bottom: -5px;
	margin-top: -4px;
}

/* ... */

Хорошо, если мы знаем метрики шрифта. В противном случае разработчик вынужден мучать девтулзы и подбирать отступы на глаз. Это приведёт к полупиксельным зазорам.

Решение: Capsize

Разработчики компании Seek сделали инструмент, который сканирует метрики шрифта через FontKit и компенсирует отступ в относительных единицах:

Так как отступы округляются до одной десятитысячной «ема», «Капсайз» решает проблему зазоров. Плюс теперь не нужна обёртка .textNode — стили назначаются на псевдоэлементы. Но разработчик по-прежнему подбирает стили для каждой пары интерлиньяжа и кегля, пусть теперь и в визуальном редакторе.

Я хочу проще. Давайте запишем метрики в ЦСС единожды, а кегль и интерлиньяж подставим через формулу.

Решение: моё

Чтобы сделать компенсацию отступа относительной:

  1. Вычтем из площадки значение, зарезервированное под выносные элементы шрифта при единичной высоте строки.
  2. Вычтем сверху и снизу из текстовой площадки половину от добавочной высоты строки:
.metrics-fix {
	&::before {
		content: '';
		display: table;
		margin-block-end: calc(-0.35em - (1lh - 1em) / 2);
	}

	&::after {
		content: '';
		display: table;
		margin-block-start: calc(-0.15em - (1lh - 1em) / 2);
	}
}

Про зарезервированное место. Здесь значения 0.35em и 0.15em автоматически рассчитаны из метрик. Они не зависят от высоты строки. 0.35em — расстояние от заглавной буквы до верхней части площадки; 0.15em — от базовой линии до нижней части площадки.

Про добавочную высоту строки. И сверху, и снизу мы вычитаем 1lh минус 1em, делённое на два. Почему так? Как только мы увеличиваем высоту строки, сверху и снизу к площадке добавляется расстояние, которое мешает обрезать строку до высоты букв. Это расстояние измеряется в «емах»: line-height: 1.3 — то же, что line-height: 1.3em. Вычитая из высоты строки один «ем», мы получаем добавочное расстояние между строками; в этом случае — 0.3em. Псевдоэлемент вычитает добавочное расстояние сверху и снизу:

Автоматизация

На этом сайте вертикальные отступы рассчитаны вплоть до субпикселя. Всё благодаря «Икс-сайзу» — виджету, который автоматически составляет псевдоэлементы с компенсацией.

«Икс-сайз» извлекает из метрик шрифта расстояния, зарезервированные под верхние и нижние выносные элементы, складывает их и получает общую высоту строки.

(UPD: после выпуска этого текста в вакансиях указывают субпиксель-пёрфект вёрстку как требование к соискателю.)

Виджет лежит на отдельной странице. Опробуйте в бою:

Икс-сайз — простая CSS-only альтернатива Capsize

Внедрение в проект

Загрузите используемые на сайте шрифты в виджет. Скопируйте код и примените стиль ко всем базовым текстовым элементам, которые используются на сайте, — спискам, абзацам, заголовкам. У меня так:

/* globals/font-metrics.css */
/* Typeface: PT Root UI */

.metrics-fix,
figcaption,
h1,
h2,
h3,
p,
dt,
dd {
	&::before {
		content: '';
		display: table;
		margin-block-end: calc(-0.35em - (1lh - 1em) / 2);
	}

	&::after {
		content: '';
		display: table;
		margin-block-start: calc(-0.15em - (1lh - 1em) / 2);
	}
}

/* Typeface: Martian Mono */

pre {
	&::before {
		content: '';
		display: table;
		margin-block-end: calc(-0.312em - (1lh - 1em) / 2);
	}

	&::after {
		content: '';
		display: table;
		margin-block-start: calc(-0.088em - (1lh - 1em) / 2);
	}
}

Класс .metrics-fix применяет компенсацию к любому блоку.

💡 Иногда стили по умолчанию мешают сверстать кастомный элемент. Например, если вы делаете список карточек с display: grid, псевдоэлементы станут элементами грида. Отключите их через ul:not(.metrics-fix--disabled).

Расхождения в метриках

Браузеры и операционные системы рендерят шрифты по-разному. Браузеры на Маке используют поля ascender и descender таблицы hhea, на Виндоусе — usWinAscent и usWinDescent таблицы head. Бывают исключения. Например, Файрфокс всегда берёт метрики из hhea.

Если в метриках есть расхождения, интерлиньяж будет плавать. Приведите метрики к общим значениям.

Макс Колер написал, на какие поля обратить внимание. Всё ок, если:

  • sTypoAscender и hheaAscender равны. То же для sTypoDescender и hheaDescender, sTypoLinegap и hheaLinegap;
  • usWinAscent равен наибольшему ymax в таблице глифов;
  • usWinDescent равен наименьшему ymin в таблице глифов, умноженному на -1.

Чтобы автоматизировать проверку, запустите скрипт, который приравняет все асцендеры к ymax и все десцендеры к ymin. В заметке есть способ через FontSquirrel, вот ещё два от меня:

  1. Зайдите на Transfonter и сконвертируйте шрифт со включенным чекбоксом Fix vertical metrics.
  2. Приравняйте Win Ascent и Win Descent к значениям таблицы Hhead в редакторе FontForge.

Полифил для lh

Единица измерения lh поддерживается в браузерах с 2023 года. Сегодня 24 ноября 2024-го, поддержка — 91,26%. Если вам нужны древние браузеры, lh заменяется кастомным свойством:

.metrics-fix {
	&::before {
		margin-block-end: calc(-0.35em - (1em * var(--lh) - 1em) / 2);
	}

	&::after {
		margin-block-start: calc(-0.15em - (1em * var(--lh) - 1em) / 2);
	}
}

Не советую использовать --lh. Свойство придётся переопределять всякий раз, когда вы задаёте line-height:

.some-component {
	--lh: 1.3;
	line-height: var(--lh);
}

Впрочем, это всё ещё проще, чем прописывать отступы вручную.

Дизайн через межстрочное расстояние

На личном сайте я задал кастомное свойство --gap, которое равно межстрочному расстоянию (line gap). Это высота строки минус высота строчных.

--rex: calc(1rem / 2);
--rlh: calc(1rem * var(--typography__leading));

--gap: calc(var(--rlh) - var(--rex));
--gap--relative: calc(1lh - 1ex);

Отступ, равный интерлиньяжу, помогает соблюдать правило внутреннего и внешнего. Например, один абзацный отступ может занимать два «гэпа»: calc(var(--gap) * 2), то есть, два интерлиньяжа.

Бонус: как устроен алгоритм

Браузер рендерит строку так, что значение высоты строки по умолчанию — line-height: initial — равно сумме высоты верхних выносных элементов (ascender), расстояния между строками (line gap) и нижних выносных элементов (descender).

Алгоритм считает, сколько в «емах» нужно убрать сверху и снизу. Снизу срезает от десцендера до базовой линии, сверху — от асцендера до прописной или до строчной в зависимости от дизайна:

Вот код на Яваскрипте:

// Считываем метрики
unitsPerEm = 1000
ascent = 1040
descent = -216
lineGap = 0
capHeight = 800
xHeight = 600

/*
 * Область, в которой может находиться глиф.
 * Она же — `line-height: initial;`
 */
const contentArea = Math.abs(descent) + lineGap + ascent

// Метрики в «емах»
const contentAreaScale = contentArea / unitsPerEm
const ascentScale = ascent / unitsPerEm
const descentScale = descent / unitsPerEm
const lineGapScale = lineGap / unitsPerEm
const capHeightScale = capHeight / unitsPerEm
const xHeightScale = xHeight / unitsPerEm

// Высота нижнего выносного элемента минус половина высоты строки
const descentTrim = (Math.abs(descentScale) + lineGapScale / 2 - (contentAreaScale - 1) / 2) * -1

/*
 * Высота верхнего выносного элемента (можно заменить на xHeightScale)
 * минус высота прописной буквы минус половина высоты строки
 */
const ascentTrim = (Math.abs(ascentScale - capHeightScale + lineGapScale / 2) - (contentAreaScale - 1) / 2) * -1

console.log(
	parseFloat(descentTrim.toFixed(3)),
	parseFloat(ascentTrim.toFixed(3))
)

Чтобы углубиться в тему

Deep dive CSS: font metrics, line-height and vertical-align. Статья Винсента Де Оливьеры о том, как браузер использует метрики шрифта при рендере и как это соотносится с высотой строки.

The Thing With Lead­ing in CSS. Матиас Отт рассказывает о прениях между разработчиками и дизайнерами по поводу высоты строки.

What length CSS unit should you use? Схема-опрос, чтобы выбрать подходящую единицу измерения в зависимости от того, что вы верстаете.

LibFont. Я построил свой инструмент на базе LibFont. Эта либа добавляет объект Font(), похожий на новый Image(). Позволяет сканировать метаданные вроде x-height и hhea.ascender.

precomputeValues.ts. Алгоритм нахождения компенсации отступов в исходном коде Capsize.

Прожмите реакцию