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

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



    Переезд с Flash


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

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

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

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

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

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


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



    Про магию. Несмотря на то, что большинство браузеров поддерживают тег 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 с этим не было проблем, т.к. он везде одинаковый.



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

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

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

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

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

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


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



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



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

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

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

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


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



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

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

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

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


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


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

    Этап 1. Разбиваем на логические блоки

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

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

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

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

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

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


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



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

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



    Единственным вариантом решения, который мы знали, было идти в спецификацию W3C для браузеров и пытаться повторять это внутри Canvas Text. Это было сложно и больно, но мы смогли добавить базовую поддержку. Подробнее про bidirectional: раз и два.

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


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

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

    Комментарии 19

      +2
      Делал как-то чем-то похожую систему. Но там было больше извращений, потому что оно было для полиграфии и итоговый макет должен был стать PDF. То есть, pixel perfect, причём во времена IE9/10, плюс специфика.

      Так вот, об извращениях — on-site редактор текста при правке именно текста отображал «ну как-то похоже» (стили CSS в основном), а при уходе фокуса текст с метаданными уходил на сервер, где генерировался этот кусочек PDF, потом Imagick'ом это счастье из PDF рендерилось в PNG, и уже этот PNG с прозрачностью отсылался обратно клиенту, где вставлялся в контейнер слоя. Ну и превью страницы (полный PDF) обновлялся в фоне, как и текст при изменении масштаба, поворотах и тому подобных преобразованиях. Статические картинки считались на клиенте.

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

      Интересно, что несмотря на монструозность конструкции — всё нормально себе работало и даже с хорошей производительностью в продакшене. Задачи суб-рендеринга прекрасно кластеризовались и масштабировались, но даже этого особо не требовалось, потому что основной сервер и так тянул.
        0
        Спасибо за материал. По письму — теоретически можно быстро добавить написание сверху-вниз для иероглифических текстов?
          0
          Вряд ли это можно будет сделать быстро. Начиная от того что нужно разобраться в правилах такого письма и точно определять его, до особенностей отрисовки в разных браузерах и системах. Вообще при работе с иероглифами в редакторе есть много нюансов
          +1
          Это значит, что если у вас есть четыре текста, которые написаны OpenSans, вам нужно четыре раза полностью загрузить этот шрифт пользователю. Такой вариант нам не подходил.

          Не понял эту часть.
          Почему нельзя просто подключить шрифт через link? Вроде работает:
          jsfiddle.net/94xrhkb1/10
            0
            Работает пока svg находится в html. Если вынести его как отдельную картинку или конвертировать в data-url внешний ресурсы перестают работать.
            jsfiddle.net/tg2who0d/1
              0

              А нельзя было закешировать шрифт в localStorage и доставать оттуда сразу в base64?

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

                  Но загружаться с сервера он все равно только один раз будет. Или из-за этого проблемы с памятью начинаются?

                    0
                    Перед добавлением на канву генерируется картинка, в которой будет шрифт. Шрифт будет загружаться не 1 раз а для каждой картинки
            +3
            Каков процент пользователей IE11 у вас?
              0
              ~3% за месяц
            • НЛО прилетело и опубликовало эту надпись здесь
                0
                Рассматривали этот вариант, и даже используем его для экспорта в pdf некоторых виджетов. Но он получался медленнее из-за того что svg приходится конвертировать в data-url
                • НЛО прилетело и опубликовало эту надпись здесь
                    0
                    По возможности только заменяли символы кодировали не всегда.
                    Не точно ответил в первый раз. Основная проблема в то что в svg не будут подключатся внешний ресурсы если его добавить на canvas.
                    Пример jsfiddle.net/ehu84Lxn/1
                0

                Пользуюсь Miro регулярно и просто в восторге. Не хватает кропа картинки не только в виде прямоугольника, но и по маске. То есть вместо такого кропа я хочу вырезать чувака отдельно, вот так.

                  0
                  а почему сразу всё не в svg?
                    0

                    У вас я смотрю выделение идёт по символам в исходном тексте. Но это же дикость, когда в какой-то момент стрелка влево начинает смещать выделение вправо. Я тоже так сначала сделал, но этим было невозможно пользоваться. В итоге сделал так, что стрелки всегда смещают выделение в правильную сторону. И для вашего примера получается, что "Javascript" заполняется справа, продолжая выделение. Да, чтобы вырезать, например, выделенную подстроку теперь нельзя воспользоваться простым substring, нужно разбивать текст на куски с одним направлением и определять какие их части попали в выделение.

                      0
                      Это стандартное поведение при вводе bi-directional текста. В редакторе все работает как в ос, проблемы у нас появились при парсинге и рисований

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

                    Самое читаемое