Организация отступов при верстке и композиции элементов на странице требует определенной сноровки, чтобы обеспечить правильные комбинации и желаемый результат.

А если в дизайн-макете запрашиваемые отступы указаны без учета высоты линии и пустых областей отрисовки шрифтов (заплечики, диакритики, нисходящая высота)? Как сделать верстку максимально приближенной к макету эффективно и без долгих ручных пересчетов? Интерлиньяж и вертикальный ритм позволяют унифицировать расстояния между элементами, но не решают вопрос компенсации расстояний с учетом текстовых метрик используемых шрифтов.

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

В данной статье мы рассмотрим подход, призванный оптимизировать рутинную работу вокруг отступов — автоматизированная корректировка отступов с учётом высоты линии целевых элементов и текстовых метрик используемых шрифтов на этапе пре- или пост-компиляции стилей.

Требование в дизайн-спецификации


И так, расстояние между элементами, содержащими текст, от базовой линии (baseline) одного элемента до верхней границы заглавной буквы (cap line) следующего элемента должно быть определенного значения (например: 24px), исключая область отрисовки диакритических знаков и заплечики (shoulders).

Design expectations

При использовании данного значения в таких декларациях, как, например, padding или margin — между элементами формируется область отступа, содержащая:

  1. Непосредственно указанное значения отступа (24px) между элементами.
  2. (Над отступом) Расстояние между базовой линией текста (baseline) и нижней границей (bottom bearing line) ограничительной рамки шрифта (font bounding box, b-box, bbox) — высота нижнего выноса (descent height).
  3. (Над отступом) Расстояние между нижней границей ограничительной рамки шрифта и нижней границей высоты линии элемента (например, 4px при указании значений 16px/1.5).
  4. (Под отступом) Расстояние между верхней границей высоты линии и верхней границей (top bearing line) ограничительной рамки шрифта. Например, 4px при указании значений 16px/1.5).
  5. (Под отступом) Расстояние между верхней границей (top bearing line) ограничительной рамки шрифта и линией, проходящей через верхнюю границу заглавной буквы (cap line), включающее область отрисовки диакритических знаков (diacritic) и заплечики (shoulders).

(Измерения шрифта)

Пример


В качестве примера рассмотрим реализацию баннера с текстом:

<div class="banner">
    <div class="banner__media">
        <div class="banner__content">
            <h2 class="banner__content-title">Men</h2>
            <p class="banner__content-description">Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text.</p>
            <ul class="banner__content-links">
                <li>
                    <a href="#">Women</a>
                </li>
                <li>
                    <a href="#">Men</a>
                </li>
                <li>
                    <a href="#">Boys</a>
                </li>
                <li>
                    <a href="#">Girls</a>
                </li>
            </ul>
        </div>
    </div>
</div>

Типографические стили, применяемые к условному проекту:

/*
Typography styles
*/

.type-h2 {
  font-family: Arial;
  font-size: 35px;
  line-height: 40px;
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 2px;

  html[lang^="de"] & {
    font-family: 'Karla Bold';
    font-size: 35px;
    line-height: 40px;
  }
}

.type-p1 {
  font-family: Arial;
  font-size: 16px;
  line-height: 22px;
  font-weight: normal;
  letter-spacing: 0;

  html[lang^="de"] & {
    font-family: 'Karla Regular';
    font-size: 16px;
    line-height: 22px;
  }
}

@media only screen and (min-width: 480px) and (max-width: 767px) {
  .type-h2 {
    font-family: Arial;
    font-size: 50px;
    line-height: 55px;

    html[lang^="de"] & {
      font-family: 'Karla Bold';
      font-size: 50px;
      line-height: 55px;
    }
  }

  .type-p1 {
    font-family: Arial;
    font-size: 16px;
    line-height: 22px;

    html[lang^="de"] & {
      font-family: 'Karla Regular';
      font-size: 16px;
      line-height: 22px;
    }
  }
}

@media only screen and (min-width: 768px) {
  .type-h2 {
    font-family: Arial;
    font-size: 80px;
    line-height: 90px;

    html[lang^="de"] & {
      font-family: 'Karla Bold';
      font-size: 80px;
      line-height: 90px;
    }
  }
  .type-p1 {
    font-family: Arial;
    font-size: 19px;
    line-height: 26px;

    html[lang^="de"] & {
      font-family: 'Karla Regular';
      font-size: 19px;
      line-height: 26px;
    }
  }
}

(Размер шрифта и высота линии переопределяются для класса заголовка (.type-h2) и параграфа (.type-p1) в зависимости от брэйкпоинта и локали.)

А также стили непосредственно для баннера:

/*
banner.scss
*/

@import 'path/to/typography.scss';

.banner {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  background: url('path/to/banner-image.png') no-repeat center bottom;
  background-size: cover;

  &__content {
    color: #fff;
    padding: 48px 0 48px 48px;

    &-title {
      @extend .type-h2;
    }

    &-description {
      @extend .type-p1;
      max-width: 80%;
      margin-top: 24px;
    }

    &-links {
      @extend .type-p1;
      display: flex;
      flex-direction: row;
      margin-top: 24px;


      li {
        margin-left: 18px;

        &:first-child {
          margin-left: 0;
        }

        a {
          color: inherit;
        }
      }
    }
  }
}

(Для наглядности, области, обозначающие оступы смещены к визуальным границам элементов)

Результат для английской локали:

Rendered text without typography adjustment for english locale

Результат для немецкой локали (заголовок изменен по сравнению с английским вариантом, чтобы продемонстрировать область диакритики):

Rendered text without typography adjustment for german locale

Как и стоило ожидать, фактически использованное значение отступов (24px) дополнилось пустыми областями высоты линий и ограничительных рамок шрифта, областью отрисовки диакритических знаков, а также областью нисходящей высоты шрифта.

Для достижения требуемого результата, были рассмотрены следующие подходы:

1. Уменьшение высоты линии текстового элемента.

Минусы:

— Не подходит для многострочного текста, так как влияет на его интерлиньяж.
— При сбрасывании до фактического значения высоты шрифта оставляет пустые области ограничительной рамки шрифта — заплечики (shoulders).

2. Смещение на «лишнюю» высоту путём использования отрицательных значений отступов непосредственно текстового элемента (или его содержащего) или, как альтернатива, его псевдоэлемента.

  .selector {
    margin-top: -6px;
  }

  .selector-2:before {
    content: '';
    display: table;
    margin-top: -6px;
  }

Минусы:

  • Затрагивает стили элемента или псевдоэлемента, которые могут быть уже использованы.
  • Требует ручного просчета и указания разных значений при использовании непохожих семейств шрифтов для различных локализаций, что подразуемевает создание дополнительных правил.
  • При изменении базовых типографических стилей приложения или смены шрифтов требует пересчета всех значений (в силу отличия текстовых метрик нового шрифта).

3. Уменьшение размера ограничительной рамки шрифта до границ рендеринга глифа, путём модификации самого шрифта.

Минусы:

  • Не подходит для системных шрифтов.
  • Не подходит для лицензионных шрифтов, так как может нарушать условия их использования и/или модификации.
  • Не решает вопрос компенсации области диакритических знаков (например, для таких символов, как Й, Ё, E, N, Y) над верхней границей заглавной буквы (cap line) и области нисходящей высоты (descending height) (например, для таких символов как p, g, j).

4. Уменьшение значения применяемого отступа для визуального соответствия требуемому значению.

Минусы:

  1. Требует просчета значения в зависимости от используемых типографических стилей целевого и/или соседнего элемента, зависящее от типографических стилей и текстовых метрик используемого шрифта.
  2. Требует указания разных значений при использовании непохожих семейств шрифтов для различных локализаций, что подразуемевает ручное создание дополнительных правил.
  3. Изменение базовых типографических стилей сайта и/или замена шрифтов подразумевает пересчет используемых значений (в силу отличия текстовых метрик нового шрифта).
  4. Наличие дополнительных правил для различных локализаций и/или медиа-запросов (media quieries) подразуемевает ручное создание и просчет всех возможных комбинаций.

Из перечисленых способов решения представленной задачи самым надежным и точным, но в то же время и самым трудоемким, является пункт 4.

Для приложений, поддерживающих различные брэйкпоинты и несколько локализаций это означает, что необходимо проводить точный просчет значений, применяемых к используемым декларациям, а также создавать дополнительные правила, учитывающие медиа-запросы, локализационные селекторы, а также текстовые метрики используемых шрифтов. Удвольствие, мягко говоря, сомнительное. Более того, временные затраты на описание точных стилей и их последующая верификация увеличивают сроки разработки, добавляя рутинную работу как разработчикам, так и тестировщикам.

Для автоматизирования процесса пересчета отступов между текстовыми элементами на основании типографических правил и текстовых метрик используемых шрифтов, а также автоматизированной поддержки всех зависимых комбинаций, используемых в типографических стилях, был создан postcss плагин postcss-text-indentation-adjustment

PostCSS плагин postcss-text-indentation-adjustment


postcss-text-indentation-adjustment — postcss плагин, позволяющий скорректировать используемые в стилях значения отступов с учетом типографических стилей, а также с учетом текстовых метрик используемых шрифтов.

Алгоритм работы:

1. Извлечение текстовых метрик, используемых в проекте шрифтов.
2. Извлечение данных из используемых типографических стилей.
3. Инициализация плагина и включение его в процесс сборки.
4. Описание корректировки значения декларации с помощью специального синтаксиса с указанием типографических селекторов.
5. Сборка проекта и получение результата корректировок со всеми возможными комбинациями внешних правил для типографических стилей (медиа-запросы, родительские селекторы и их комбинации).

Преимущества:

  1. Используется оригинальное значение отступа (например, из дизайн-макета или типографического стайлгайда), что позволяет централизованно хранить эти значения в переменных и при изменении дизайн требований — с легкостью изменять исходные значения.
  2. Значение отступов корректируется таким образом, чтобы вычесть пустые области высоты линии, ограничительной рамки шрифта, области отрисовки диакритических знаков и нисходящей высоты шрифта, используя значения типографических стилей для текстовых элементов, относительно которых нужно уменьшить расстояние.
  3. При наличии нескольких правил в типографических стилях, зависящих на медиа-запросы (media-query) или специфические родительские селекторы (например, html[lang^=«de»], .parent-classname) — автоматически создаются необходимые дополнительные правила для целевой декларации, чьё значение необходимо скорректировать, приняв во внимание медиа-запросы и/или родительские селекторы, а также просчитав финальные значения декларации с учетом используемых высот линий и текстовых метрик подключаемых шрифтов.

Пример правил, применяемых к типографическому стилю заголовка с классом .type-h2, описывающий переопределение семейства шрифта, а также его размер и высоту линии для определенной ширины экрана и немецкой локали:

.type-h2 {
  font-family: Arial;
  font-size: 35px;
  line-height: 40px;
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 2px;

  html[lang^="de"] & {
    font-family: 'Karla Bold';
    font-size: 38px;
    line-height: 46px;
  }
}

@media only screen and (min-width: 768px) {
.type-h2 {
  font-family: Arial;
  font-size: 80px;
  line-height: 90px;

  html[lang^="de"] & {
    font-family: 'Karla Bold';
    font-size: 86px;
    line-height: 94px;
  }
}

4. При использовании css препроцессора (например, sass, scss, less) — поддерживается вложенность обрабатываемых правил, а также использование переменных.

Точность корректировок плагина зависит от текстовых метрик шрифтов, передаваемых в качестве аргумента в момент его инициализации, поэтому прежде чем интегрировать плагин postcss-text-indentation-adjustment в наш пример, рассмотрим как текстовые метрики могут быть извлечены.

Извлечение текстовых метрик


Описанный далее способ извлечения текстовых метрик оформлен в отдельный пакет font-metrics и позволяет получить текстовые метрики из локально сохраненных, системных или расположенных удаленно шрифтов, путем использования CSS Font Loading API, отрисовывая текст на холсте в браузере Chrome при включенном флаге «experimentalCanvasFeatures: true» с последующим сохранением значений в файл.

Для извлечения текстовых метрик воспользуемся методом контекста холста measureText. Согласно документации TextMetrics единственное доступное значение объекта TextMetrics при отрисовке в браузере — ширина отрисованного текста (width).

Для решения нашей задачи необходимы более детальные параметры, такие как fontBoundingBoxAscent (расстояние от базовой линии до верхней границы ограничительной рамки шрифта), fontBoundingBoxDescent (расстояние между базовой линией и нижней границей ограничительной рамки шрифта), а также alphabeticBaseline (расстояние между выбранной для отрисовки базовой линией и алфавитной базовой линией) и hangingBaseline (расстояние между выбранной для отрисовки базовой линией и верхней границей отрисовки глифа без учета области диакритических знаков), доступные лишь в хроме при включенном флаге ExperimentalCanvasFeatures: true.

Сумма значение fontBoundingBoxAscent и fontBoundingBoxDescent даст фактическую высоту области отрисовки ограничительной рамки шрифта, а разница значений hangingBaseline и alphabeticBaseline — фактическую высоту прописной буквы (cap height) без области диакритических знаков, заплечиков и нисходящей высоты.

Так как значения представлены в CSS пикселях и будут высчитаны относительно значения font-size, использованного для отрисовки текста — можно воспользоваться отношением разницы высоты ограничительной рамки шрифта и высоты прописной буквы к высоте прописной буквы. Это позволит вычислить относительное значение высоты исключаемых областей для целевого размера шрифта, используемого в наших стилях.

Но обо всем по порядку.

Запуск хрома с флагом ExperimentalCanvasFeatures


Нам потребуется html страница с созданным на ней холстом, доступная для нашего скрипта.
Для манипуляции с холстом в браузере с включенным флагом ExperimentalCanvasFeatures, воспользуемся библиотекой Nightmare:

import Nightmare from 'nightmare';
import fse from 'fs-extra';

const browser = Nightmare({
  show: false,
  webPreferences: {
    experimentalCanvasFeatures: true
  }
});
const pageUrl = 'your/path/to/canvas/page';

// параметры шрифтов, относительно которых нужно снять текстовые метрики
const fontsData = {
    fonts: [{
    fontFamily: 'Arial'
  }, {
    fontFamily: 'Karla Regular',
    src: '//fonts.gstatic.com/s/karla/v6/S1bXQ0LrY7AzefpgNae9sYDGDUGfDkXyfkzVDelzfFk.woff2'
  }, {
    fontFamily: 'Karla Bold',
    src: '//fonts.gstatic.com/s/karla/v6/r3NqIkFHFaF3esZDc3WT5BkAz4rYn47Zy2rvigWQf6w.woff2'
  }],
  fontSize: 24
};

browser.gotTo(pageUrl)
  .evaluate(data => {
    // Так как часть шрифтов может быть расположена удаленно и потребовать время на загрузку - используем Promise
    return new window.Promise((rootResolve, rootReject) => {
      const {fonts, fontSize} = data;

      // При наличии не системных, а подключаемых извне или локально шрифтов, добавим их в очередь на загрузку, обернув в Promise
      const fontsToLoad = fonts.reduce((result, fontData) => {
        const {src, fontFamily} = fontData;

        // При остутствии параметра src будем считать, что используется системный шрифт
        if (typeof src === 'string') {
          return result;
        }

        const promise = new window.Promise((resolve, reject) => {
            // Для подключения нового шрифта воспользуемся конструктором FontFace
            const fontFace = new window.FontFace(fontFamily, `url(${encodeURI(src)})`);
            // и добавим новый font face, когда шрифт будет загружен
            fontFace.load().then(function () {
              document.fonts.add(fontFace);
              resolve();
            }).catch(function (err) {
              reject(err);
            });
          });

          return promise;
      }, []);

      const fontsMetrics = {};

      // Как только все шрифты загружены - последовательно отрисуем их на холсте и извлечем текстовые метрики
      window.Promise.all(fontsToLoad).then(() => {
        const canvas = document.getElementById('canvas');
        const ctx = canvas.getContext('2d');
        // В качестве базовой линии используем алфавитную базовую линию
        ctx.textBaseline = 'alphabetical';

        fonts.forEach(fontData => {
          // Применим шрифт к холсту и извлечем текстовые метрики
          ctx.font = `${fontSize}px ${fontData.fontFamily}`;
          const metrics = ctx.measureText('Example');

          fontsMetrics[fontData.fontFamily] = metrics;
        });
      });

      rootResolve(fontsMetrics);
    });
  }, fontsData)
  .end()
  .then(result => {
    // Сохраним текстовые метрики в файл для последующего использования
    const content = JSON.stringify(result, null, 4);

    fse.outputFile('desired/path/to/metrics.json', content, function (err) {
      if (err) {
        console.error(err);
      }
    });
  });

В результате получим текстовые метрики необходимых шрифтов:

{
  "Arial": {
    "actualBoundingBoxAscent": 0,
    "actualBoundingBoxDescent": 24,
    "actualBoundingBoxLeft": 0,
    "actualBoundingBoxRight": 93,
    "alphabeticBaseline": 0,
    "emHeightAscent": 0,
    "emHeightDescent": 0,
    "fontBoundingBoxAscent": 22,
    "fontBoundingBoxDescent": 5,
    "hangingBaseline": 17.600000381469727,
    "ideographicBaseline": -5,
    "width": 93.375
  },
  "Karla Bold": {
    "actualBoundingBoxAscent": 0,
    "actualBoundingBoxDescent": 24,
    "actualBoundingBoxLeft": 0,
    "actualBoundingBoxRight": 85,
    "alphabeticBaseline": 0,
    "emHeightAscent": 0,
    "emHeightDescent": 0,
    "fontBoundingBoxAscent": 22,
    "fontBoundingBoxDescent": 6,
    "hangingBaseline": 17.600000381469727,
    "ideographicBaseline": -6,
    "width": 85.30078125
  },
  "Karla Regular": {
    "actualBoundingBoxAscent": 0,
    "actualBoundingBoxDescent": 24,
    "actualBoundingBoxLeft": 0,
    "actualBoundingBoxRight": 85,
    "alphabeticBaseline": 0,
    "emHeightAscent": 0,
    "emHeightDescent": 0,
    "fontBoundingBoxAscent": 22,
    "fontBoundingBoxDescent": 6,
    "hangingBaseline": 17.600000381469727,
    "ideographicBaseline": -6,
    "width": 85.30078125
  }
}

В рассматриваемом примере потребуются текстовые метрики лишь для шрифтов Arial, «Karla Bold» и «Karla Regular», но на практике это может быть любой доступный системный, кастомный или доставляемый через cdn шрифт в том количестве, которое необходимо для поддержания всех локализаций вашего проекта.

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

При использовании пакета font-metrics процесс генерации текстовых метрик значительно упрощается:

import fontMetrics from 'font-metrics';

const fontParser = fontMetrics({
  fonts: [{
    fontFamily: 'Arial'
  }, {
    fontFamily: 'Karla Regular',
    src: '//fonts.gstatic.com/s/karla/v6/S1bXQ0LrY7AzefpgNae9sYDGDUGfDkXyfkzVDelzfFk.woff2'
  }, {
    fontFamily: 'Karla Bold',
    src: '//fonts.gstatic.com/s/karla/v6/r3NqIkFHFaF3esZDc3WT5BkAz4rYn47Zy2rvigWQf6w.woff2'
  }],
  output: './font-metrics',
  filename: 'font-metrics.json'
});

fontParser.parse();

Результат:

{
  "metrics": {
    "Arial": {
      "_fontSize": 24,
      "_textBaseline": "alphabetic",
      "actualBoundingBoxAscent": 0,
      "actualBoundingBoxDescent": 24,
      "actualBoundingBoxLeft": 0,
      "actualBoundingBoxRight": 93,
      "alphabeticBaseline": 0,
      "emHeightAscent": 0,
      "emHeightDescent": 0,
      "fontBoundingBoxAscent": 22,
      "fontBoundingBoxDescent": 5,
      "hangingBaseline": 17.600000381469727,
      "ideographicBaseline": -5,
      "width": 93.375
    },
    "Karla Bold": {
      "_fontSize": 24,
      "_textBaseline": "alphabetic",
      "actualBoundingBoxAscent": 0,
      "actualBoundingBoxDescent": 24,
      "actualBoundingBoxLeft": 0,
      "actualBoundingBoxRight": 85,
      "alphabeticBaseline": 0,
      "emHeightAscent": 0,
      "emHeightDescent": 0,
      "fontBoundingBoxAscent": 22,
      "fontBoundingBoxDescent": 6,
      "hangingBaseline": 17.600000381469727,
      "ideographicBaseline": -6,
      "width": 85.30078125
    },
    "Karla Regular": {
      "_fontSize": 24,
      "_textBaseline": "alphabetic",
      "actualBoundingBoxAscent": 0,
      "actualBoundingBoxDescent": 24,
      "actualBoundingBoxLeft": 0,
      "actualBoundingBoxRight": 85,
      "alphabeticBaseline": 0,
      "emHeightAscent": 0,
      "emHeightDescent": 0,
      "fontBoundingBoxAscent": 22,
      "fontBoundingBoxDescent": 6,
      "hangingBaseline": 17.600000381469727,
      "ideographicBaseline": -6,
      "width": 85.30078125
    }
  },
  "src": [
    {
      "fontFamily": "Arial"
    },
    {
      "fontFamily": "Karla Regular",
      "src": "//fonts.gstatic.com/s/karla/v6/S1bXQ0LrY7AzefpgNae9sYDGDUGfDkXyfkzVDelzfFk.woff2"
    },
    {
      "fontFamily": "Karla Bold",
      "src": "//fonts.gstatic.com/s/karla/v6/r3NqIkFHFaF3esZDc3WT5BkAz4rYn47Zy2rvigWQf6w.woff2"
    }
  ]
}

Интеграция плагина postcss-text-indentation-adjustment


Принцип работы плагина postcss-text-indentation-adjustment основывается на описании корректировок исходного значения, применяемого к декларации (в рассматриваемом примере это 24px) с указанием типографических классов, относительно которых нужно скорректировать это значение. Описание корректировки выполняется в виде комментария внутри значения декларации и позволяет безопасно внедрять плагин в сборку проекта.

Рассмотрим, как корректировки могут быть описаны для нашего примера:

/*
banner.scss
*/

@import 'path/to/typography.scss';

.banner {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  background: url('path/to/banner-image.png') no-repeat center bottom;
  background-size: cover;

  &__content {
    color: #fff;
    padding: 48px 0 48px 48px /* {48px, .type-h2} 0 {48px, .type-p1} 48px */;

    &-title {
      @extend .type-h2;
    }

    &-description {
      @extend .type-p1;
      max-width: 80%;
      margin-top: 24px /* {24px, .type-h2, .type-p1} */;
    }

    &-links {
      @extend .type-p1;
      display: flex;
      flex-direction: row;
      margin-top: 24px /* {24px, .type-p1, .type-p1} */;


      li {
        margin-left: 18px;

        &:first-child {
          margin-left: 0;
        }

        a {
          color: inherit;
        }
      }
    }
  }
}

Обработка корректировок


Импортируем необходимые библиотеки.

// node-sass для компиляции исходного файла типографических стилей
import nodeSass from 'node-sass';
// postcss для манипуляции со стилями
import postcss from 'postcss';
// fse или fs для чтения исходного файла типографических стилей и сохранения результата
import fse from 'fs-extra';
// path для нормализации пути к файлу
import path from 'path';
// postcss-scss для использования postcss до компиляции scss файлов в css
import postcssSCSS from 'postcss-scss';
// postcss-text-indentation-adjustment для парсинга типографических стилей, создания корректировок и комбинаций зависимых правил
import textIndentationAdjustment, {parser} from 'postcss-text-indentation-adjustment';
// postcss-partial-import для инлайн ипорта scss файлов, использованных в основных стилях проекта
import postcssPartialImport from 'postcss-partial-import';
// css-mqpacker для группировки медиа-запросов (или любой другой инструмент)
import cssMqPacker from 'css-mqpacker';
// postcss-merge-rules для объединения css селекторов на основании идентичных деклараций (или любой другой инструмент)
import mergeRules from 'postcss-merge-rules';
// Текстовые метрики, извлеченные на одном из предыдущих шагов
import {metrics} from 'path/to/metrics.json';

// Скомпилируем типографические стили из scss в css для их последующего парсинга
const typography = nodeSass.renderSync({
  file: 'path/to/typography.scss'
}).css.toString();

// Инициализируем парсер, передав текстовые метрики, полученные на одном из предыдущих шагов
const typographyParser = parser({
  metrics: metrics
});

// Распарсим типографические стили для их последующего использования
const parsedTypography = typographyParser.parse(typography);

// Инициализируем postcss плагин, передав подоготовленные парсером данные типографических стилей
const typographyAdjustmentPlugin = textIndentationAdjustment({
  corrections: parsedTypography,
  plainCSS: false // при использовании плагина для корректировки значений на этапе препроцессинга стилей (например, при использовании scss)
});

// Используем postcss с синтаксисом scss

fse.readFile('path/to/banner.scss', (err, scss) => {
  postcss([postcssPartialImport(), postcssTypographyAdjustmentPlugin])
    .process(scss, {
      syntax: postcssSCSS,
      from: 'path/to/banner.scss',
      to: `path/to/banner.css`
    })
    .then(postcssResult => {
      return new Promise((resolve, reject) => {
        nodeSass.render({
          data: postcssResult.css,
          outputStyle: 'expanded'
        }, (err, result) => {
          resolve(result);
        });
      });
    })
    .then(result => {
      // Объединим медиа-запросы и правила с одинаковыми декларациями
      return postcss([mergeRules(), cssMqPacker()]).process(result.css);
    })
    .then(result => {
      fse.outputFile('path/to/banner.css', result.css);
    })
    .catch(e => {
      console.log(e);
    });
});

В результате будет скомпилирован файл, содержащий следующие правила (типографические стили и результат работы @extend исключены, чтобы сделать акцент на сгенерированных корректировках):

.banner {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  background: url("path/to/banner-image.png") no-repeat center bottom;
  background-size: cover;
}

.banner__content {
  color: #fff;
  padding: 39.5px 0 43px 48px;
}

.banner__content-description {
  max-width: 80%;
  margin-top: 10.5px;
}

html[lang^="de"] .banner__content-description {
  margin-top: 10.5px;
}

.banner__content-links {
  display: flex;
  flex-direction: row;
  margin-top: 14px;
}

.banner__content-links li {
  margin-left: 18px;
}

.banner__content-links li:first-child {
  margin-left: 0;
}

.banner__content-links li a {
  color: inherit;
}

html[lang^="de"] .banner__content-links {
  margin-top: 14px;
}

html[lang^="de"] .banner__content {
  padding: 39.5px 0 43px 48px;
}

@media only screen and (min-width: 480px) and (max-width: 767px) {
  .banner__content-description {
    margin-top: 8.5px;
  }
  html[lang^="de"] .banner__content-description {
    margin-top: 7.5px;
  }
  .banner__content-links {
    margin-top: 14px;
  }
  html[lang^="de"] .banner__content-links {
    margin-top: 14px;
  }
  .banner__content {
    padding: 37.5px 0 43px 48px;
  }
  html[lang^="de"] .banner__content {
    padding: 36.5px 0 43px 48px;
  }
}

@media only screen and (min-width: 768px) {
  .banner__content-description {
    margin-top: -0.5px;
  }
  html[lang^="de"] .banner__content-description {
    margin-top: -1.5px;
  }
  .banner__content-links {
    margin-top: 11px;
  }
  html[lang^="de"] .banner__content-links {
    margin-top: 11px;
  }
  .banner__content {
    padding: 30px 0 41.5px 48px;
  }
  html[lang^="de"] .banner__content {
    padding: 29px 0 41.5px 48px;
  }
}

Результат для английской локали:







Результат для немецкой локали:







Как можно увидеть, результат рендеринга отступов существенно скорректировался.

Синтаксис корректировок


(Скомпилированные значения дополнительно пропущены через плагины объединения селекторов и медиа-запросов — css-mqpacker, postcss-merge-rules)

1. Корректировки выполняются в виде комментария и располагаются внутри значения декларации.

.rule-selector {
  padding-top: 24px /*  */;
}

2. Каждое значение, которое должно быть скорректировано, обрамляется в фигурные скобки (корректирующая группа).

.rule-selector {
  padding-top: 24px /* {24px} */;
}

3. Типографические классы или теги, относительно которых корректировки должны быть применены, указываются через запятую после базового значения.

.rule-selector {
  padding-top: 24px /* {24px, h3, .type-h2} */;
}

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

.rule-selector {
  padding-top: 24px 0 24px /* {24px, h3, .type-h2} 0 {24px, .type-h2, .copy-p1} */;
}

5. Контент комментария с результатом вычисления всех корректирующих групп устанавливается плагином в финальное значение декларации.

.rule-selector {
  padding-top: 17px 0 19px;
}

6. При наличии родительских селекторов в исходном файле типографических классов — создается дополнительное правило, включающее комбинацию родительского и целевого селектора, а также декларации с высчитанными значениями для всех комбинаций типографических селекторов.

/* typography */
.p1 {
  font-size: 16px;
  line-height: 24px;
  font-family: Arial;

  .parent-selector-1 & {
    font-size: 18px;
    line-height: 26px;
    font-family: Arial;
  }
}

.p2 {
  font-size: 18px;
  line-height: 22px;
  font-family: Arial;

  .parent-selector-2 & {
    font-size: 14px;
    line-height: 20px;
    font-family: Arial;
  }
}

/* input */
.rule-selector {
  padding-top: 24px /* {24px, .p1, .p2} */;
}

/* output */
.rule-selector {
  padding-top: 13px;
}

.parent-selector-1 .rule-selector {
  padding-top: 12px;
}

.parent-selector-2 .rule-selector {
  padding-top: 13px;
}

7. При наличии медиа-запросов или медиа-запросов в комбинации с родительскими селекторами — создаются все возможные комбинации для типографических селекторов, участвующих в корректировке.

/* typography */
.p1 {
  font-size: 16px;
  line-height: 24px;
  font-family: Arial;

  .parent-selector-1 & {
    font-size: 18px;
    line-height: 26px;
    font-family: Arial;
  }

  @media (min-width: 768px) {
    font-size: 22px;
    line-height: 28px;
    font-family: Arial;

    html[lang^="de"] .parent-selector-3 & {
      font-size: 28px;
      line-height: 40px;
      font-family: Arial;
    }
  }
}

.p2 {
  font-size: 18px;
  line-height: 22px;
  font-family: Arial;

  .parent-selector-2 & {
    font-size: 14px;
    line-height: 20px;
    font-family: Arial;
  }

  @media (min-width: 321px) {
    font-size: 22px;
    line-height: 28px;
    font-family: Arial;

    html[lang^="de"] .parent-selector-4 & {
      font-size: 28px;
      line-height: 40px;
      font-family: Arial;
    }
  }
}

/* input */
.rule-selector {
  padding-top: 24px /* {24px, .p1, .p2} */;
}

/* output */
.rule-selector {
  padding-top: 13px;
}

.parent-selector-1 .rule-selector {
  padding-top: 12px;
}

.parent-selector-2 .rule-selector {
  padding-top: 13px;
}

@media (min-width: 768px) {
  .rule-selector {
    padding-top: 13px;
  }
  html[lang^="de"] .parent-selector-3 .rule-selector {
    padding-top: 14px;
  }
}

@media (min-width: 321px) {
  .rule-selector {
    padding-top: 12px;
  }
  html[lang^="de"] .parent-selector-4 .rule-selector {
    padding-top: 14px;
  }
}

8. Проверить параметры, использованные для вычисления финальных значений можно с помощью флага --debug

/* input */
.rule-selector {
  padding-top: 24px /* {24px, .p1, .p2} --debug */;
}

В результате в консоль будет выведена часть данных, используемых для создания корректировки.

Особенности использования актуальной на момент написания статьи версии (1.0.9)


  • Все значения, используемые при вычислении, должны быть указаны в пикселях.
  • Каждое правило в типографических стилях должно содержать такие параметры шрифта, как font-family, font-size и line-height, указанные по отдельности, или объединенные в одну декларацию font.
  • При использовании с препроцессорами на этапе прекомпиляции стилей, необходимо установить опцию plainCSS в значение false.

const typographyAdjustmentPlugin = textIndentationAdjustment({
  corrections: parsedTypography,
  plainCSS: false
});

В заключение


  • Сокращает в разы время разработки постоянно развивающихся приложений, поддерживающих различные брэйкпоинты и несколько локализаций, где огромное значение уделяется точности верстки и дизайна.
  • Легко настраивается для любого шрифта, достаточно лишь единожды извлечь текстовые метрики и использовать их в процессе компиляции стилей.
  • Интегрируется в любой тип сборки, поддерживаемый postcss.
  • Постоянно дорабатывается с целью улучшения качества выполняемых корректировок

Спасибо за внимание. Буду рад услышать разумную критику, а также предложения по улучшению и оптимизации.

Комментарии (3)


  1. dom1n1k
    18.12.2017 22:18

    Всё это, конечно, очень интересно и впечатляюще. Но как-то запутанно. Запутанно именно в плане практического применения; сама по себе задача мне хорошо понятна и знакома.
    Не лучше ли было бы для «клиентов класса люкс» найти/обучить дизайнера анатомии шрифтов, дабы он ваял макеты уже с учетом «лишнего» пространства? Ну то есть фиксировал отступы не от глифов, а от контейнеров.


    1. Dashukin Автор
      19.12.2017 00:29

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

      Описанное решение является следствием ряда факторов, на каждый из которых по отдельности или на их совокупность в целом невозможно было оказать существенного влияния.
      Представьте ситуацию, когда степень педантичности и уровень точности, предъявляемый к желаемому результату, имеют решающее значение, а проект представляет унаследованную кодовую базу, где элементы интерфейса и их стили уже описаны «магическими» значениями в попытке добиться желаемой точности для поддержки 10+ локализаций, часть из которых используют шрифты с процентом пустых областей порядка 10-15% от необходимого размера. Модификация интерфейса в этом случае подразумевает многочисленные ручные пересчеты, что существенно сказывается на итоговых временных оценках. Добавим сюда распределенную команду дизайнеров со стороны клиента, работающих одновременно с десятком направлений и без унифицированного стайлгайда.

      Кейс очень и очень специфический, поэтому предлагаю рассматривать описанный подход не как золотую пулю, подходящую каждому проекту, но как не совсем стандартный способ решения достаточно распространенной задачи. Возможно кто-то найдет в нем свои преимущества.


  1. Boyd_Rice
    19.12.2017 13:52

    Спасибо, интересно