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

Вместо использования готовых шрифтов я создала свой собственный, используя p5.js и JavaScript.

Как вообще работают шрифты

Я начала с того, что узнала, как называются разные точки шрифта.

Огромный респект тому, кто решил, что одна из высот по оси Y шрифта должна обозначаться как «x-высота»
Огромный респект тому, кто решил, что одна из высот по оси Y шрифта должна обозначаться как «x-высота»

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

В классе Letter я определила высоту x, высоту cap и т. д., в зависимости от размера шрифта и точки mid. Например, полная высота от base до cap равна размеру шрифта. Расстояние от y_mid до y_x равно 1/3 от полной высоты.

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

this.adj_1 = this.h_full * 0.05;
this.adj_15 = this.h_full * 0.075;
this.adj_2 = this.h_full * 0.1;
this.adj_25 = this.h_full * 0.125;
this.adj_3 = this.h_full * 0.15;
this.adj_35 = this.h_full * 0.175;
this.adj_4 = this.h_full * 0.2;

Определяем буквы

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

В результате получаются неровные каракули.

create_a(){   
  this.paths = [
    [ // stem
      {x: this.x_left+this.adj_2, y: this.y_x + this.adj_4},
      {x: this.x_left+this.adj_3, y: this.y_x + this.adj_1},
      {x: this.x_mid+this.adj_2, y: this.y_x},
      {x: this.x_right, y: this.y_x+this.adj_2},
      {x: this.x_right, y: this.y_base-this.adj_4},
      {x: this.x_right+this.adj_1, y: this.y_base},
    ],
    [ // round
      {x: this.x_right-this.adj_1, y: this.y_mid-this.adj_15},
      {x: this.x_mid-this.adj_1, y: this.y_mid-this.adj_1},
      {x: this.x_left, y: this.y_mid+this.adj_35},          
      {x: this.x_mid, y: this.y_base},
      {x: this.x_mid+this.adj_2, y: this.y_base-this.adj_1},
      {x: this.x_right+this.adj_2, y: this.y_base - this.adj_4},
    ]
  ];
}

Работаем с кривыми

Следующий шаг — сглаживание траекторий с помощью алгоритма кривых Чайкина. Давайте рассмотрим, как он работает.

Алгоритм Чайкина работает рекурсивно, и в каждом раунде мы создаём новый путь, выполняя следующие действия:

  • Копируем первую точку

  • Для остальных точек перед последней точкой:

    • Добавим точку со смещением на 25% к предыдущей точке

    • Добавим точку со смещением на 25% пути к следующей точке.

  • Копируем последнюю точку.

После одного прохода у нас получилось вот что. Новый путь отмечен красным цветом.

Повторяем процедуру с тем, что у нас получилось. Вот результаты после 2 и 3 прогонов.

И конечный результат.

Неплохо. Давайте попробуем провернуть то же самое с буквами. После первого прогона:

После третьего:

Выглядит неплохо:

3–4 итераций алгоритма достаточно, чтобы получить красивую кривую при небольших размерах символов. Если шрифт будет большого размера (с точками, расположенными дальше друг от друга), то просто нужно выполнить больше итераций.

Минимизируй это

Определение контуров относительно точек mid, cap и т. д. помогло мне понять, как рисовать букву. Например, мне было проще думать: «основание b начинается на cap и идёт к base», а не «основание b начинается на 14,1 пикселя выше точки mid и заканчивается на 7,4 пикселя ниже неё».

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

// Get string of new code
    let string = "";
    for(let l of this.letters){
      string += "create_" + l.letter + "(){\n";
      string += "  this.ip = [\n";
      for(let path of l.ip){
        string += "  [";
        for(let p of path){
          string += "{x: " + nf(p.x, 0, 1) + ", y: " + nf(p.y, 0, 1)  + "}";
          if (path.indexOf(p) != path.length-1) string += ", ";
        }
        string += "]";
        if (l.ip.indexOf(path) != l.ip.length-1) string += ",\n";
        else string += "\n";
      }
      string += "  ]\n";
      string += "}\n";
    }
    console.log(string)

Вот новый код для буквы а. Гораздо лаконичнее и проще.

create_a(){
  this.ip = [
    [{x: -2.8, y: -3.4}, {x: -1.7, y: -6.8}, {x: 2.3, y: -8.0}, {x: 5.4, y: -5.7}, {x: 5.4, y: 2.9}, {x: 6.6, y: 7.4}],
    [{x: 4.3, y: -1.7}, {x: -0.9, y: -1.1}, {x: -5.1, y: 3.9}, {x: -2.1, y: 7.4}, {x: 2.3, y: 6.3}, {x: 5.4, y: 2.9}]
  ]
}

Эти цифры основаны на кегле 20 и масштабируются для разных размеров шрифта.

Формируем естественную толщину

Вот так стал выглядеть весь мой алфавит. Выглядит довольно естественно, но всё равно что‑то не то.

Толщину линий, которыми отрисованы буквы, легко отрегулировать. Но рукописные буквы — они же не одинаковые. Разная сила нажатия = разная толщина линии.

Чтобы добиться большей естественности, я преобразовала буквы в двухмерные фигуры с помощью алгоритма, который я называю «shapify».

Вот уже знакомая вам зигзагообразная форма, после того, как я превратила её в кривую.

Чтобы добиться эффекта естественного утолщения/утончения линий букв, то есть создать шейпированный контур, пройдём по нему и в каждой точке:

  • Найдём угол от этой точки до следующей (для последней точки находим угол к предыдущей точке и переворачиваем его на 180°).

  • Используя шум Перлина, выбираем ширину контура в этой точке.

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

Отсюда можно понять, как создать контур с изменяемой шириной линии. Обратите внимание, как создаётся небольшая петля из точек на 180°, чтобы создать красивую круглую линию на искривлении буквы.

Примечание: мой алгоритм 'shapify' совсем не идеален. Когда ширина штриха большая, неуклюжие внутренние петли появляются на крутых углах.

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

Наводим дополнительную красоту

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

А можно и сам шум усилить, чтобы буквы стали более неровными.

Сколько это весит

Мой шрифт занимает 9,7 Кб. В нём есть:

  • Заданные траектории для всех букв A‑Z в нижнем и верхнем регистре, а также 7 знаков препинания.

  • Расстояние между буквами (это ещё можно донастроить).

  • Функция для изменения размера линий букв в зависимости от размера шрифта.

  • Функция для создания гладкой линии букв путём вызова алгоритма Чайкина.

  • Функции для создания и рисования объектов NaturalLine, которые определяют неровность точек линий, изменение формы и т. д. 

Можно ли уменьшить размер файла? Да, конечно. Пока этим вопросом я не озадачивалась. Когда только начинала работу над этим проектом, то переживала, что делаю бесполезную работу. А теперь мне прям нравится.

Думаете, я на этом остановилась? Как бы не так. Через пару месяцев мне захотелось сделать курсивную версию моего шрифта. На тот момент всё выглядело так:

  • Написан код для определения ключевых точек в линиях каждой буквы (~10 точек на букву).

  • Придумано сглаживание этих путей с использованием алгоритма кривых Чайкина.

  • Настроена переменная толщина линий.

  • Контуры фигур отрисовываются с помощью p5js.

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

Оформляем буквы

В редакторе p5js я создала инструмент для определения и вывода ключевых точек в контурах линий букв. Там можно в несколько кликов разместить ключевые точки траектории — будет показана результирующая траектория в форме кривой Чайкина. Можно перетаскивать точки, чтобы получить нужную форму буквы. Я создавала по 2–3 варианта для каждой буквы.

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

[{x:0.7,y:22.5},{x:8.2,y:18.1},{x:8.9,y:11.2},{x:3.7,y:11.4},{x:1.7,y:18.9},{x:8.4,y:22.4},{x:17.7,y:22.0}] 

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

Числа на бумаге — это координаты XY, необходимые для того, чтобы эта область попала в окно создания буквы. После создания всех контуров, их изгибания и превращения в фигуры переменной ширины, на выходе у меня получилось вот что:

Курсивирование, курсификация, курва… ой, всё

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

Рассмотрим пару букв na. Красным цветом мы выделим последнюю точку буквы n, которая находится внизу, а зелёным — первую точку буквы a, которая находится вверху. Это приводит к тому, что соединительный путь (мы же слитно пишем, правда?) проходит по диагонали через букву a, что делает её немного похожей на букву e.

Между тем, в паре ti буква t заканчивается чуть выше линии base, а затем на ней начинается i, образуя неестественный выступ.

Чтобы это исправить, мы можем добавить дополнительную точку в начало a и удалить последние две точки в t.

Но делать это для всех сценариев — чересчур. Тем более что если a находится в начале слова, дополнительная точка будет не на своём месте, а если перед a стоит буква вроде w, то она создаст линию, пересекающую a другим способом. Если t сочетается с k, то она деформируется.

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

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

  • Неможет присоединиться к другой букве (0)

  • Присоединяется к другой букве вокруг линии base(1)

  • Присоединяется к другой букве чуть выше линии base (2)

  • Присоединяется к другой букве на высоте x (3)

Вот несколько примеров:

Теперь каждый путь буквы выглядит примерно так. Обратите внимание на отдельные цифры в начале и конце:

[0,{x:12.2,y:13.2},{x:13.5,y:11.0},{x:6.2,y:8.4},{x:1.1,y:13.0},{x:1.8,y:19.0},{x:7.0,y:23.4},{x:15.2,y:23.6},{x:18.4,y:22.1},1],

Протестировав все пары букв, я получила вот что:

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

Создание слов

Когда создаётся слово:

  • для каждой буквы выбирается базовый путь из 2–3 вариантов для этого символа.

  • информация о концах путей буквы передаётся соседним буквам.

  • Основные пути букв корректируются в зависимости от соседних. Например, если высота конца предыдущей буквы равна 2, удалите 1 точку из начала этого пути, или если высота начала следующей буквы равна 1, добавьте дополнительную точку в определённом месте.

Функции настройки иногда могут показаться сложными. Вот пример для буквы q:

// ip = path 
// pc = previous char's end info 
// nc = next char's start info 
// n = index of path that was chosen for this letter
adjust: (ip, pc, nc, n) => {
  // randomly adds in a break at the end for 70% of this letter
  if (rand() < 0.7 ) ip.splice(-1, 1, 0);

   // if [2] was chosen for this path from the 4 options, 
   if (n < 2) {

     // Swap out first two points for a different point if the previous char ends at 3
     if (pc == 3) ip.splice(1, 2, {x:10,y:12});

     // Otherwise, as long as it's not a 0, add a point at the beginning
     else if (pc > 0) ip.splice(1, 0, {x:10,y:20});
  }

  // If there's no break (0) between this character and the next
  if (nc > 0 && ip[ip.length-1] != 0){
    // Swap out the last two points for a different one 
    ip.splice(-3, 2, {x:16,y:34})
  }
}

А для буквы n всё довольно просто:

adjust: (ip, pc, nc) => {
  // If the next letter starts at a 3, randomly either create a break or move the last point 
  if (nc == 3) rand() < 0.3 ? ip.splice(-1, 1, 0) : ip.splice(-2, 1, {x:17,y:23.8})
}

Затем основные пути для всех букв объединяются вместе. При этом игнорируются 1, 2 и 3 в путях букв, зато когда есть 0, то создаётся разрыв и начинается новая линия слова.

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

Для сравнения: два листа. Один из них заполнен мною от руки, другой — напечатан.

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

Спасибо за внимание!

Примеры работ

P.S. Бонус для тех, кто дочитал статью до конца. В понедельник, 9 сентября, мы запустим новый ИТ-квест. Если вы не знаете, что это такое, то вот ссылка на разбор предыдущего. Следите за новостями, в этот раз загадки будут сложнее и разнообразнее!

Ваш Cloud4Y.

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


  1. harlow
    05.09.2024 13:47
    +2

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


    1. engine9
      05.09.2024 13:47
      +1

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


  1. Milliard
    05.09.2024 13:47
    +2

    1. Krasnoarmeec
      05.09.2024 13:47

      Некоторые статьи просто необходимо дублировать.

      Да, было, но стоит и повторить.

      Красивая работа, хороший перевод.


      1. FreeNickname
        05.09.2024 13:47

        Кстати, а Хабр не показывает предупреждение, если пытаешься создать перевод уже переведённой статьи? В уже переведённой статье ведь есть ссылка на этот же оригинал.

        Если что, я не покритиковать, правда интересно.


        1. Krasnoarmeec
          05.09.2024 13:47

          Как видите, никаких предупреждений нет.

          Как по мне, так ничего особо криминального в этом не вижу. Оба перевода на уровне. Оригинальная статья тоже интересная. Лично я - плюсанул, даже за ещё один перевод.