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

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

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


Привет, Хабр! Меня зовут Сергей Симонов, в МойОфис я занимаюсь разработкой веб-версии редакторов «МойОфис Текст» и «МойОфис Таблица». Ниже я подробно расскажу об их техническом устройстве, а именно:

  • о ключевых архитектурных решениях, принятых при создании приложений;

  • особенностях кроссплатформенности;

  • использовании кода на «неродном» языке на стороне JavaScript.

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

Архитектурные решения

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

  • приложения должны уметь открывать документы распространенных форматов — созданные как в наших, так в сторонних редакторах;

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

  • приложения должны поддерживать возможность совместного редактирования — одновременную работу нескольких пользователей над одним документом без каких-либо конфликтов;

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

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

С учетом всего этого архитекторы МойОфис спроектировали ядро — центральную составляющую системы, написанную на C++. Именно в ядре реализована основная функциональность, вокруг него строятся все наши редакторы на всех платформах.

Для чего нужно ядро?

Рассмотрим, каким образом ядро реализует наши базовые требования.

  1. Открытие документов

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

Существует два глобальных формата документов, OOXML и ODF. Отличий между ними достаточно — например, в объектных моделях, — и для каждого из этих форматов в ядре предусмотрен свой генератор нашего внутреннего DOM. При открытии документа, в зависимости от входящего формата, ядро задействует соответствующий генератор и строит наш универсальный DOM, описывающий документ.

Далее этот DOM используется другими сущностями ядра. В данном случае нас интересует сущность Layout, которая из DOM, древовидной иерархической структуры, делает плоскую структуру, однозначно описывающую разметку документа. А также сущность Painter, которая используется для того, чтобы на основе разметки описать команды для отображения документа.

В итоге у нас есть DOM, Layout и Painter — с помощью этих сущностей мы полностью закрываем первую потребность: возможность открывать и отображать документы различных форматов.

  1. Наличие в редакторах привычной пользователям функциональности

Это требование также реализовано в ядре. Суть основной функциональности — во взаимодействии с упомянутым ранее DOM: добавлении тех или иных элементов в объектную модель, изменении их свойств, удалении элементов из DOM.

  1. Мультиплатформенность

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

  1. Совместное редактирование

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

Для понимания принципов работы OT рассмотрим пример. Допустим, два пользователя одновременно редактируют документ, и начинают они с одного и того же состояния — обозначим его как «a, b, c». Предположим, первый пользователь удаляет символ под индексом 2, то есть букву c, и получает в своей локальной ветке — назовем это так — результат a, b. Второй пользователь в свою очередь вставляет символ в начало по индексу 0 — букву d — и получает в своей локальной ветке d, a, b, c. Результаты разные, нужно как-то привести их к одному виду. Если мы просто применим операции пользователей друг к другу, то получим разный результат.

На помощь приходит функция трансформации, которая в случае необходимости прогонит эти операции через себя и поменяет в них индексы. В нашем случае мы спокойно можем применить для первого пользователя операцию второго и вставить в начало символ d; в результате мы получим d, a, b. Для второго же пользователя мы не можем просто так применить операцию первого — нужно поменять индекс. И именно функция трансформации делает это. Мы удаляем символ не под индексом 2, а под индексом 3 — и получаем тот же результат, что и у первого пользователя.

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

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

Что нам даёт ядро

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

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

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

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

Устройство веб-редакторов

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

Начнем, опять же, с ядра. Как я уже упоминал, ядро написано на C++, и нам требовалось заставить его работать в браузере. Здесь на ум сразу приходит WebAssembly — формат байт-кода, который позволяет исполнять C++ (и не только) на стороне браузера. Чтобы из исходного кода ядра получить WebAssembly, мы используем Emscripten — компилятор, предназначенный как раз для этих целей.

Рассмотрим этот процесс подробнее. Первое, что мы делаем — собираем статическую библиотеку из исходников ядра; впоследствии на основе этой библиотеки мы и будем компилировать наш WebAssembly. Далее пишем биндинги на сущности ядра, которые являются инструкциями для компилятора. Затем, на основе написанных биндингов и нашей библиотеки, мы с помощью Emscripten компилируем ядро в WebAssembly. Получившийся результат — бинарники wasm и сгенерированный JavaScript-код — публикуем в качестве npm-пакета, который в дальнейшем можно использовать на стороне нашего приложения.

Для наглядности приведу пример биндингов на простой класс:

class_<EditorsCore::AutoFillHelper>("AutoFillHelper")
        .smart_ptr<EditorsCore::AutoFillHelperPtr>("std::shared_ptr<EditorsCore::AutoFillHelper>")
        .function("setMode", &EditorsCore::AutoFillHelper::setMode)
        .function("moveToPoint",
                  select_overload<void(const RTE::TouchPoint &)>(&EditorsCore::AutoFillHelper::moveTo))
        .function("moveToCell",
                  select_overload<void(const CellIndex &)>(&EditorsCore::AutoFillHelper::moveTo))
        .function("apply", &EditorsCore::AutoFillHelper::apply)
        .function("getTooltip", &EditorsCore::AutoFillHelper::getTooltip)
        .function("getStatus", &EditorsCore::AutoFillHelper::getStatus)
        .function("getTableRange", &EditorsCore::AutoFillHelper::getTableRange);

Если погрузиться в документацию Emscripten, то с написанием биндингов особых проблем не возникнет (кроме самой необходимости смотреть и понимать плюсовый код). Однако бывают сценарии, когда ядро предоставляет нам не готовую сущность, которую нужно просто забиндить, а интерфейс, который нужно сначала реализовать, а затем уже написать биндинги на свою реализацию. Поскольку мы фронтенд-разработчики и C++ не наш конек, обновление ядра в таком сценарии может занимать до целой рабочей недели.

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

Для связи UI и ядра мы используем Brant — наш самописный пакет. Он позволяет регистрировать в main thread сервисы, живущие в web worker, для вызова их методов. После отказа от поддержки IE мы постепенно переходим на Comlink, легковесную библиотеку от ребят из Google, которая позволяет делать, по сути, то же самое.

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

Рендеринг

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

Наша реализация этого интерфейса называется WebCanvasPainter. Она формирует ArrayBuffer из команд, которые ядро вызывает при отрисовке документа. Позже этот ArrayBuffer обрабатывается на стороне нашего JavaScript кода, разбивается обратно на последовательность команд, которые в конечном итоге маппятся на нативные методы Canvas API.

Посмотрим на WebCanvasPainter, чтобы чуть больше понимать принцип работы.

class WebCanvasPainter final : public Gfx::Painter
{
public:
    WebCanvasPainter();
    
    void setLineWidth(Gfx::LineWidth lineWidth) override;
    void setLineType(Gfx::LineType type) override;
    void setFillColor(const ColorRGBA& fillColor) override;

    void drawText(StringView text,
                  const Gfx::ExtFontInfoConstNNPtr& fontInfo,
                  const Gfx::FontMetrics& fontMetrics) override;
    void fillRect(const Rect& rect) override;
    void drawImage(const Rect& rect, const unsigned char* imageData, size_t dataSize) override;
    void drawChart(const Rect& rect, const Gfx::ChartInfo& chart) override;

    void beginPath() override;
    void moveTo(const Point& point) override;
    void lineTo(const Point& point) override;
    void ellipseArcTo(const Rect& rect, Unit startAngle, Unit swingAngle) override;
    void cubicBezTo(const Point& cPoint1, const Point& cPoint2, const Point& point) override;
    void quadBezTo(const Point& cPoint, const Point& point) override;
    void closePath() override;

    void setTransform(const Gfx::Transform& transform) override;
    // ...

private:
    WebBufferRPC _renderingBuffer;
    // ...
}

Мы видим, что он содержит набор примитивных методов, которые по сути своей являются командами для отрисовки. Всего методов порядка 40. Когда ядро вызывает тот или иной метод, мы записываем в буфер информацию о том, что был вызван этот метод с указанными аргументами. Реализация метода setFillColor выглядит так:

void WebCanvasPainter::setFillColor(const ColorRGBA& color)
{
    _renderingBuffer.setValue((uint8_t)PainterActions::setFillColor);
    _renderingBuffer.setValue(color.r);
    _renderingBuffer.setValue(color.g);
    _renderingBuffer.setValue(color.b);
    _renderingBuffer.setValue((float)(color.a / 255.0f));
}

В итоге для следующего примера при распаковке буфера получаем подобный набор команд:

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

Добавление символа в пустой документ происходит следующим образом. Нажимаем клавишу, считываем символ, который нажали, вызываем метод insertText у ядра.

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

Для каждой видимой страницы вызываем наш метод drawPage, в котором создаем экземпляр WebCanvasPainter. Затем вызываем метод ядра drawPage, передавая этот экземпляр.

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

На этом общение с ядром заканчивается, дальше дело техники. Обрабатываем ArrayBuffer, разбиваем на последовательность команд, в данном случае получаем только drawText с аргументами. Передаем это в canvasAdaptеr, который подготовит наш «холст», установит шрифт и смаппит команду drawText на метод Canvas API fillText. FillText — это конечная точка.

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

Реализация шрифтов

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

Шрифты в наших редакторах делятся на две категории:

  1. Наши собственные шрифты

  1. Шрифты с открытой лицензией

Для каждого шрифта существуют по четыре начертания: regular, bold, italic и bold italic. Все .ttf файлы мы грузим при открытии документа параллельно с инициализацией ядра.

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

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

В случае, если пользователь откроет документ с шрифтом, которого нет в таблице маппинга, мы возьмем наш шрифт по умолчанию — XO Thames, который совпадает по метрикам c Times New Roman. Хочу отметить, что механизм маппинга никак не меняет оригинальный шрифт в документе, он лишь помогает отображать документы корректнее.

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

***

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

Если вам интересны другие аспекты разработки продукта, дайте знать об этом в комментариях — мы будем рады обратной связи!

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


  1. pocheketa
    15.03.2024 07:43
    +1

    Посмотрите в сторону LaTeX...


    1. madyouth Автор
      15.03.2024 07:43

      Добрый день! В десктоп-версиях наших редакторов можно работать с LaTeX. Что касается веб-версий, мы работаем над этим – представим эту возможность в одном из будущих обновлений.


  1. kspshnik
    15.03.2024 07:43

    А как вы решаете конфликты при объединении результатов нескольких пользователей?

    Например, один из "a, б, в" сделал "а, б, г", а другой "а, б, д" ? На основании чего будет приниматься решение, какая из версий пойдёт в итоговый документ?


  1. voldemar_d
    15.03.2024 07:43

    drawText(StringView

    Правильно я понимаю, что у вас собственный класс "строка"? Можете рассказать, почему и зачем?

    Чем не устраивает хранение строк в кодировке UTF8 в std::string?