Как мы учились рисовать тексты на Canvas

Мы разрабатываем платформу для визуальной коллаборации. Для отображения контента мы используем Canvas: на нём рисуется всё, в том числе тексты. Готового решения для отображения текстов на Canvas один в один как в html не существует. За несколько лет работы с отрисовкой текстов мы изучили разные варианты реализации, набили много шишек и, кажется, нашли хорошее решение. Расскажу в статье, как мы переезжали с Flash на Canvas и почему отказались от SVG foreignObject.

jfx3vm1-ffhat8wfv1jwvc5vf6i.gif

Переезд с Flash


Мы создавали продукт в 2015 году на Flash. Внутри Flash есть текстовый редактор, умеющий хорошо работать с текстами, поэтому нам не нужно было делать ничего дополнительного для работы с текстами. Но в то время Flash уже умирал, поэтому мы переехали с него на HTML/Canvas. И перед нами встала задача — отображать текст на Canvas как в html-редакторе, при этом не сломать при переезде тексты, созданные во Flash-версии.

Мы хотели сделать так, чтобы пользователь мог редактировать текст прямо в нашем продукте, не замечая перехода между режимами редактирования и отрисовки. Решение мы видели такое: при клике на область с текстом открывается текстовый редактор, в котором можно менять текст; редактор можно закрыть, убирая курсор с области с текстом. При этом отображение текста на Canvas должно 1 в 1 соответствовать отображению текста в редакторе.

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

Мы рассмотрели несколько вариантов решения:

  • Стандартный Canvas.fillText. Умеет рисовать текст как в html, можно стилизовать, работает во всех браузерах. Но не умеет отрисовывать ссылки как в html-редакторе b многострочные тексты с разным форматированием. Эти трудности решаемы, но требуют большого количества времени;
  • Рисовать DOM поверх Canvas. Вариант нам не подошёл, т.к. у нас в продукте у каждого созданного объекта есть свой z-index на canvas. И перемешивать его с DOM z-index не получится.
  • Конвертировать html в svg. Он умеет превращать html в изображение благодаря элементу foreignObject. Это позволяет запекать html внутрь svg и работать с ним как с изображением. Этот вариант мы и выбрали.


Особенности работы SVG foreignObject


Как работает SVG foreignObject: у нас есть HTML из редактора → помещаем HTML в foreignObject → немного магии → получаем изображение → добавляем изображение на canvas

hjhtgpn56gwrvg1vuoeqqxpbssi.png

Про магию. Несмотря на то, что большинство браузеров поддерживают тег foreignObject, у каждого есть свои особенности для использование результата с canvas. FireFox работает с Blob объектом, в Edge нужно делать Base64 для изображения и возвращать data-url, а в IE11 тег вообще не работает.

getImageUrl(svg: string, browser: string): string {
  let dataUrl = ''

  switch (browser) {
     case browsers.FIREFOX:
        let domUrl = window.URL || window.webkitURL || window
        let blob = new Blob([svg], {type: 'image/svg+xml;charset=utf-8'})
        dataUrl = domUrl.createObjectURL(blob)
        break
     case browsers.EDGE:
        let encodedSvg = encodeURIComponent(svg)
        dataUrl = 'data:image/svg+xml;base64,' + btoa(window.unescape(encodedSvg))
        break
     default:
        dataUrl = 'data:image/svg+xml,' + encodeURIComponent(svg)
  
  return dataUrl
}


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

mb63fvrwyib4m-tnl_1xre8j55w.png

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

Во-вторых, экспериментальным путём пришли к тому, что необходимо добавить несколько необычных css-стилей для редактора и svg, чтобы уменьшить разницу отображения между браузерами:

  • font-kerning: auto; управляет кернингом шрифта. Подробнее
  • webkit-font-smoothing: antialiased; отвечает за сглаживание. Подробнее.

Что в итоге мы получили благодаря SVG:

  • Можем рисовать любой html: текст, таблицы, графики
  • Тег возвращает векторную картинку
  • Тег работает во всех современных браузерах, кроме IE11


Почему мы отказались от foreignObject


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

ud90haadatev9z1dvo2qjnu5tle.png

Мы задумались, можем ли мы сделать это с помощью foreignObject. Оказалось, что у него есть особенность, которая при решении этой задачи становится фатальным недостатком. Он может отображать HTML внутри себя, но не может обращаться к внешним ресурсам, поэтому все ресурсы, с которыми он работает, необходимо конвертировать в base64 и добавлять внутрь svg.

bjeb-gztne3x_o8tefz7701bgs0.png

Это значит, что если у вас есть четыре текста, которые написаны OpenSans, вам нужно четыре раза полностью загрузить этот шрифт пользователю. Такой вариант нам не подходил.

Мы решили, что напишем свой Canvas Text с… хорошей производительностью, поддержкой векторного изображения, не забудем про IE 11

Почему нам важно векторное изображение? В нашем продукте любой объект на доске может зумится, а с векторным изображением мы можем создать его только один раз и переиспользовать вне зависимости от зума. Canvas.fillText рисует растровое изображение: в этом случае нам при каждом зуме нужно перерисовывать изображение, что, как мы думали, сильно сказывается на производительности.

Создаём прототип


Первым делом мы создали простой прототип, чтобы проверить его производительность.

dcktwh05pk52qdr6jiipfyna-lo.png

Принцип работы прототипа:

  • Отдаём в функцию «текст»;
  • Из неё получаем объект, в котором есть каждое слово из текста, с координатами и стилями для отрисовки;
  • Отдаём объект в Canvas;
  • Canvas рисует текст.


У прототипа было несколько задач: проверить, что перерисовка Canvas при скейле будет проходить без задержек и что время превращения html в объект будет не больше, чем создание svg-изображения.

С первой задачей прототип справился, скейл почти не влиял на производительность при рисований текстов. Со второй задачей возникли проблемы: обработка больших объёмов текста занимает достаточно времени и первые замеры производительности показали плохие результаты. Для отрисовки текста из 1К символов новому подходу требовалось почти в 2 раза больше времени, чем svg.

zixtcvj3c0sn-pcbfq77glwuhhe.png

Мы решили воспользоваться самым надёжным способом оптимизаций кода — «заменить тест на нужный нам» ;-). А если серьёзно, то мы пошли к аналитикам и спросили, какой длины тексты чаще всего создают наши пользователи. Оказалось, что средний размер текста — 14 символов. Для таких коротких текстов наш прототип показал значительно лучшие результаты производительности, т.к. зависимость скорости от объёма текста у него линейная, а обёртка в svg выполняется почти всегда за одно и тоже время, независимо от длины текста. Это нас устраивало: мы можем проиграть в производительности на длинных текстах, но для большинства случаев наша скорость будет лучше, чем у svg.

0okx4sbw1txhov2dipas8dpysji.png

После нескольких итераций работы над обновлением Canvas Text у нас получился такой алгоритм:
Этап 1. Разбиваем на логические блоки

  1. Разбиваем текст на блоки: абзацы, списки;
  2. Разбиваем блоки на меньшие блоки по стилям;
  3. Разбиваем блоки на слова.


Этап 2. Собираем в один объект с координатами и стилями

  1. Считаем ширину и высоту каждого слова в px;
  2. Соединяем разделенные слова, так как пункта 2 некоторые слова разделились на несколько;
  3. Из слов собираем строки, если слово не влазит в строку, обрезаем пока не влезет;
  4. Собираем абзацы и списки;
  5. Рассчитываем x, y для каждого слова;
  6. Получаем готовый объект для отрисовки.


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

В итоге мы сделали поддержку шрифтов и IE 11, покрыли всё юнит-тестами, а скорость отрисовки в большинстве случаев стала выше, чем у. Проверили на бета-пользователях и зарелизили. Кажется, успех!

Успех длился 30 минут


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

avgqikn29xgk7fx5xajj171rh5g.gif

К счастью, добавить поддержку правосторонней системы письма оказалось несложно, так как стандартный Canvas.fillText уже её поддерживает.

Но пока мы разбирались с этим, встретили ещё более интересные кейсы, которые fillText поддерживать уже не умел. Мы столкнулись с bidirectional текстами, в которых часть текста пишется справа налево, потом слева направо и снова справа налево.

ixobgrf-sffclz5p2rbsikodizo.gif

Единственным вариантом решения, который мы знали, было идти в спецификацию W3C для браузеров и пытаться повторять это внутри Canvas Text. Это было сложно и больно, но мы смогли добавить базовую поддержку. Подробнее про bidirectional: w3.org/International/articles/inline-bidi-markup/uba-basics и unicode.org/reports/tr9/.

Краткие выводы, которые мы сделали для себя


  1. Для отображения HTML в картинке использовать SVG foreignObject;
  2. Всегда анализировать свой продукт для принятия решений;
  3. Делать прототипы. Они могут показать, что сложные решения могут лишь казаться такими на первый взгляд;
  4. Писать код сразу так, чтобы его можно было покрыть тестами;
  5. В международных продуктаз важно не забывать, что существует много разных языков, в том числе biderectional.


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

© Habrahabr.ru