Рендеринг текста вас ненавидит +104



Рендеринг текста: насколько сложным он может быть? Оказывается, невероятно сложным! Насколько мне известно, буквально ни одна система не выводит текст «идеально». Где-то лучше, где-то хуже.

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

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

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

1. Терминология


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

Символы (characters):

  • Скаляр (scalar): юникодный скаляр, «наименьшая единица» в Юникоде (она же кодовая точка).
  • Символ (character): расширенный кластер графем Юникода (EGC), «наибольшая единица» в Юникоде (потенциально состоящая из нескольких скаляров).
  • Глиф (glyph): атомарная единица рендеринга, выдаваемая шрифтом. Как правило, имеет уникальный идентификатор в шрифте.
  • Лигатура (ligature): глиф, состоящий из нескольких скаляров и потенциально даже нескольких символов (носители языка могут представлять лигатуру как несколько символов, но для шрифта это всего лишь один символ).
  • Эмодзи: «полноцветный» глиф.

Шрифты (fonts):

  • Шрифт (font): документ, который сопоставляет символы с глифами.
  • Письмо/письменность (script): набор глифов, которые составляют некий язык (шрифты, как правило, реализуют определённые письменности).
  • Рукописный шрифт (cursive script): любой шрифт, в котором глифы соприкасаются и перетекают друг в друга (например, арабский).
  • Цвет (color): RGB и альфа-значения для шрифтов (не требуется для некоторых вариантов использования, но это интересно).
  • Стиль (style): полужирный и курсивный модификаторы для шрифтов (в практических реализациях хинтинг, псевдонимы и другие настройки обычно тоже поставляются в комплекте).

2. Стиль, вёрстка и форма зависят друг от друга?


Для представления, как работает типичный конвейер отрисовки текста, вот краткая схема:

  1. Стилизация (разбор разметки, система запросов для шрифтов).
  2. Вёрстка (разбиение текста на строки).
  3. Придание формы, шейпинг (вычисление глифов и их положений).
  4. Растеризация необходимых глифов в текстурный атлас/кэш).
  5. Композиция (копирование глифов из атласа в нужные позиции).

К сожалению, эти шаги не такие простые, как может показаться.

Большинство шрифтов на самом деле не выдают по требованию все возможные глифы. Глифов слишком много, поэтому шрифты обычно реализуют только определённое письмо. Конечные пользователи обычно не знают или не заботятся об этом, поэтому надёжная система должна каскадировать в другие шрифты, если символы недоступны.

Например, несмотря на то, что разметка следующего текста не предполагает наличия нескольких шрифтов, но это необходимо для правильного рендеринга на любой системе: hello ??? ? ??? ?. Так мы опасно приближаемся к тому, что шаг 1 (стилизация) начинает зависеть от шага 3 (придание формы)!

(Как вариант, можете принять подход Noto и использовать один шрифт Uber, который содержит все символы. Хотя тогда пользователи не смогут настроить шрифт, а вы не сможете предоставить «нативный» текстовый интерфейс пользователям на всех платформах. Но предположим, что вам нужно более надёжное решение).

Аналогично, для вёрстки нужно знать, сколько места занимает каждый фрагмент текста, но это становится известно только после шейпинга! Шаг 2 зависит от результатов шага 3?

Но для придания формы нужно знать вёрстку и стиль, так что мы, кажется, застряли. Что же делать?

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

Для каждого символа (EGC) в нашем тексте опрашиваем каждый шрифт в списке (каскаде), известны ли ему все скаляры, составляющие этот символ. Если да — используем их. Если мы доберёмся до конца списка без результата, то получим тофу (, индикатор отсутствующего глифа).

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

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

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

3. Текст — это не отдельные символы


Если судить только по английскому, то вы можете подумать, что лигатуры — какая-то причудливая ерунда. Я имею в виду, кого действительно волнует, что «?» пишется как «ae»? Но оказывается, что некоторые языки по сути целиком состоят из лигатур. Например, «?? ? ???» состоит из отдельных символов «?? ? ? ? ?». В любой продвинутой системе визуализации текста (то есть в любом из основных браузеров), эти две строки будут выглядеть очень по-разному.

И нет: речь не о разнице между скалярами Юникода и кластерами расширенных графем. Если вы попросите надёжную юникодовую систему (например, Swift) выдать кластеры расширенных графем этой строки, она выдаст эти пять символов!

Форма символа зависит от соседей: текст невозможно правильно вывести символ за символом.

То есть вы должны использовать библиотеку форм (shaping library). Отраслевым стандартом здесь является HarfBuzz, и эти задачи чрезвычайно трудно решить самостоятельно. Так что используйте HarfBuzz.

3.1. Наложения текста


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

Давайте ещё раз взглянем на «??? ? ???». Вроде, выглядит нормально? Теперь увеличим:



Всё ещё кажется прекрасным, но сделаем текст частично прозрачным. Если вы на Safari или Edge, то текст может выглядеть нормально! Но на Firefox или Chrome вид ужасный:



Проблема в том, что Chrome и Firefox пытаются схитрить. Они правильно формировали текст, но как только встречают такие глифы, то по-прежнему пытаются нарисовать их по отдельности. Обычно это работает нормально, за исключением случаев, когда есть прозрачность и перекрытия, на которых получаются такие затемнения.

«Корректная» реализация выведет текст на временную поверхность без прозрачности, а затем — на сцену с прозрачностью. Firefox и Chrome этого не делают, потому что это дорого и обычно не нужно для основных западных языков. Интересно, что они действительно понимают проблему, потому что специально обрабатывают такой сценарий для эмодзи (но мы вернёмся к этому позже).

3.2. Стиль может изменить лигатуру


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



Вот как они выглядят в Safari:



Так они выглядят в Chrome (при использовании его новой реализации макетирования):



И вот они в Firefox:



В итоге:

  • Safari неадекватен
  • Chrome разбирает глифы, но отбрасывает много цветов
  • Firefox одновременно и разбирает глифы, и отображает цвета

Думаю, что все должны равняться на Firefox, не так ли? Но если увеличить масштаб, мы увидим, что он делает нечто очень странное:



Он просто разделил эту лигатуру на четыре равные части с разными цветами!

Проблема в том, что на самом деле нет разумного ответа, как здесь следует поступать. Мы разделили лигатуру на разные стили, и поскольку лигатура в некотором смысле является «единицей» рендеринга, разумно просто отказаться от поддержки такого разделения (как и делает большинство).

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

Есть некоторый смысл в попытке поддерживать эти «частичные лигатуры»: только шейпинг может знать, будет ли выводиться конкретная лигатура, и это зависит от системных шрифтов, поэтому лигатура может появиться там, где её никто не ожидал! Классический англоязычный пример — лигатура ? из установленного пользователем шрифта на границе гиперссылки.

Также довольно странно, что английский может изменить стиль посреди слова, а рукописные шрифты не могут?

Даже не спрашивайте о коде, который ломает строки с частичными лигатурами.

4. Эмодзи ломают цвет и стиль


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



Обычно у эмодзи собственные родные цвета, и у этого цвета может быть даже семантическое значение, как в случае модификаторов цвета кожи. Более того: у них может быть несколько цветов!

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

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

Однако это означает, что при рисовании «одного» глифа у вас может неоднократно изменяться стиль. Это также означает, что «один» глиф может перекрывать сам себя, что приводит к проблемам с прозрачностью, упомянутым в предыдущем разделе. И всё же браузеры действительно правильно сочетают прозрачность слоёв в эмодзи!

Это несоответствие можно объяснить тремя способами:

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

Выбирайте вариант на свой вкус.

И ещё, как выделить смайлик курсивом или жирным? Игнорировать эти стили? Стоит ли их синтезировать? Кто знает…

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

Да, по какой-то причине куча систем тайно увеличивает размер шрифта для эмодзи, чтобы они выглядели лучше.

5. Сглаживание — это ад


Символы в тексте очень маленькие и детализированные. Очень важно, чтобы текст легко читался. Звучит как задача для сглаживания! Чёрт, а ведь 480p это действительно низкое разрешение. Ещё больше сглаживания!!!

Итак, есть два основных вида:

  • Сглаживание в оттенках серого
  • Субпиксельное сглаживание

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

Термин «оттенки серого» используется для одномерного цвета, как и наша одномерная прозрачность (в противном случае глифы выводятся одним сплошным цветом). Кроме того, в типичной ситуации чёрного текста на белом фоне сглаживание буквально отображает серые оттенки по краям.

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

Пиксели вашего монитора на самом деле представляют собой три маленьких столбца красного, зелёного и синего цвета. Если вы хотите получить красный вы как бы указываете «белый чёрный чёрный». Точно так же, если хотите получить синий цвет, то указываете «чёрный чёрный белый». Другими словами, если повозиться с цветами, то можно утроить горизонтальное разрешение и получить гораздо больше деталей!

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

(В целом эта система также означает, что цвет пиктограммы может случайно изменить её воспринимаемый размер и положение, что действительно раздражает).

Таким образом, субпиксельное сглаживание — это реально чистый хак, который может значительно улучшить разборчивость текста, отлично! Но, к сожалению, это ещё и огромная заноза в заднице!

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

Чтобы понять это, представьте чёрный квадрат 1x1 со сглаживанием в оттенках серого:

  • Если его субпиксельное смещение равно 0, то при растеризации выходит просто чёрный пиксель.
  • Если субпиксельное смещение равно 0,5, то при растеризации выходит два пикселя на 50% серого цвета.

5.1. Субпиксельные смещения ломают кэш глифов


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

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

5.2. Субпиксели сглаживания не могут быть составными


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

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

Можно подумать, что это «исправляется» просто новой растеризацией глифа в новом местоположении. И действительно, если преобразование является статическим, это может сработать. Но если преобразование является анимацией, получится ещё хуже. Это на самом деле очень распространённая ошибка браузера: если он не обнаружил, что анимация происходит с текстом, то символы будут дёргаться, поскольку каждый глиф перескакивает между различными субпиксельными привязками с хинтингом на каждом кадре.

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

Кроме того, субпиксельное сглаживание сложно использовать при наличии частичной прозрачности. По сути, здесь мы настраиваем наши каналы R, G и B для кодирования трёх значений прозрачности (по одному для каждого субпикселя), но у самого текста тоже есть цвет, и у фона, так что информация легко теряется.

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

…Кроме Firefox. Опять же, в этой странной организации кто-то действительно увлёкся и сделал нечто сложное: альфа-компонент. Оказывается, вы можете на самом деле правильно составить текст с субпиксельным сглаживанием, но это требует трёх дополнительных каналов прозрачности для R, G и B. Неудивительно, что такое сглаживание удваивает расход памяти.

К счастью, с годами субпиксельное сглаживание стало менее актуальным:

  • Дисплеи Retina вообще в нём не нуждаются.
  • Субпиксельная компоновка на телефонах блокирует этот трюк (без серьёзной работы).
  • В более новых версиях MacOS субпиксельное отображение текста по умолчанию отключено на уровне ОС.
  • Chrome, похоже, более агрессивно отключает субпиксельное сглаживание (не уверен, что это точная политика).
  • Новый графический бэкенд Firefox (webrender) для простоты отказался от компонента Alpha.

6. Эзотерика


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

6.1. Шрифты могут содержать SVG


Вот же отстой. Эти шрифты в основном предоставляются Adobe, потому что некоторое время назад они хорошенько вляпались в SVG. Иногда вы можете просто игнорировать части SVG (я считаю, что шрифт Source Code Pro технически содержит некоторые глифы SVG, но на практике они фактически не используются веб-сайтами), но в целом придётся реализовать поддержку SVG, чтобы формально поддерживать все шрифты.

А ещё вы слышали об анимированных шрифтах SVG? Нет? Хорошо. Думаю, что они везде или сломаны, или не реализованы (Firefox случайно поддерживал их некоторое время из-за какого-то разработчика-энтузиаста).

6.2. Символы могут быть чертовски большими


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

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

6.3. Выделение — это не рамка, а текст идёт во всех направлениях


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

Итак, вот вам забавный текст:

Всем привет ??? ???? ?? бип бип!!


Если на десктопе выделить текст мышью слева направо, то выделение становится прерывистым и как-то странно дёргается посередине. Это потому, что мы смешиваем в одной строке текст слева направо и справа налево, что происходит постоянно.

Сначала выделение в правую сторону увеличивает выделение, но затем уменьшает его, пока оно внезапно не начнёт увеличиваться снова. Это на самом деле совершенно правильно: выделение просто остаётся непрерывным в фактической строке. Таким образом, вы можете корректно скопировать фрагмент текста.

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

Но это ещё не всё.


Надеюсь, вам не придётся иметь дело с такими вещами.

6.4. Как написать то, что невозможно написать?


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

Но подождите, мы используем текст, чтобы объяснить, что не можем вывести текст? Хм.

Вы можете сказать, что в системе должен быть базовый шрифт, который всегда покажет символы 0-9 и A-F, но это предположение для слабаков. Если пользователь действительно уничтожил свои инструменты с помощью своих инструментов, то Firefox предлагает выход: микрошрифт!

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



6.5. Стиль является частью шрифта (за исключением случаев, когда это не так)


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

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

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

В любом случае, нужен синтетический запасной вариант. К счастью, реализация на самом деле довольно проста:

Синтетический курсив: наклонить каждый глиф.

Синтетический полужирный: отрисовать каждый глиф несколько раз с небольшим смещением в направлении текста.

Честно говоря, эти подходы довольно неплохо справляются! Но пользователи могут заметить, что всё кажется «неправильным». Поэтому можно сделать лучше, если приложить усилия.

6.6. Нет идеального текстового рендеринга


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

Ваши конфигурации должны учитывать систему пользователя, конкретные шрифты, конкретные приложения и конкретные тексты. Вы также должны попытаться соответствовать нативному «внешнему виду» каждой платформы (такие причуды).

Это включает в себя:

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

Это также означает, что следует использовать нативные текстовые библиотеки, чтобы соответствовать эстетике каждой системы (Core Text, DirectWrite и FreeType на соответствующих платформах).

7. Дополнительные ссылки


Вот ещё несколько статей о кошмаре текстового рендеринга:




К сожалению, не доступен сервер mySQL