Гибридное приложение PyQt + React

Safoyeth 25 Янв 2019

Давным-давно я ничего не писал на PyQt… Так уж сложилось исторически, что последнее время PyQt я использую только для каких-то набросков, после чего перехожу на чистый Qt. С помощью python я могу быстро и криво набросать идею, для реализации которой использую родной Qt, который выправляет кривизну рук сам. Таким образом и случилось однажды, что открыв в очередной раз sublime text и накидав очередную заготовку, я узнал о том, что QWebKit (как и когда-то Phonon!) был выпилен из Qt и теперь всем предлагается пользоваться неким QtWebEngine.

Попробовал и оказался в восторге! К моему немалому удивлению, оказалось, что QtWebEngine сразу, безо всяких танцев с бубнами и прочего шаманства умеет выполнять javascript! Причём любой, хоть из файла html, хоть из локального файла js, хоть из cdn. Сходу получив такой инструмент я уже не мог остановиться до тех пор, пока не написал работающее приложение. А написав его, понял, что хочу не просто javascript, но React! И вот оно случилось…

Итак, что я написал и о чём будет эта статья? Я написал парсер старого доброго цитатника рунета. Весь интерфейс отрисовывается с помощью React, в качестве бандлера выступает Parcel. Всё это хозяйство запускается в centralWidget QMainWindow, а непосредственно само получение и парсинг цитат происходит с помощью библиотек requests и re соответственно. По сути у нас получается клиент-серверное приложение, где html-js-css выполняет роль клиента, а Qt/PyQt — сервера.

Сразу скажу, что статья по использованию QWebKit и React на чистом Qt будет! А пока мы начнём с python.

Для работы нам потребуется терминал. Поскольку я писал всё это хозяйство под Windows, использоваться будет cmd. Когда QtWebEngine только появился он требовал MSVC, как сейчас не знаю…

Клиентская часть. Пишем простое React-приложение

Открываем терминал и создаём директорию командой mkdir pyqt-react-bash, переходим в неё и создаём новый проект npm init. Как вы понимаете, node должен быть установлен в системе. Отвечайте на вопросы npm как хотите, можно просто всё пропустить. Если вы так сделали, то в директории pyqt-react-bash (ну или как вы там её назвали) появится файл package.json такого содержания:

Дальше необходимо поставить две тонны библиотек (о веб, ты ой!). В моём случае — parcel нужно установить глобально, то есть npm install -g parcel-bundler, остальные с флагом --save-dev. Последовательно или сразу пишем в терминале

  • npm install react --save-dev
  • npm install react-dom --save-dev
  • npm install react-addons-css-transition-group --save-dev
  • npm install babel-preset-env --save-dev
  • npm install babel-preset-react --save-dev
  • npm install babel-preset-stage-0 --save-dev
  • npm install babel-preset-stage-1 --save-dev
  • npm install babel-plugin-react-html-attrs --save-dev
  • npm install babel-plugin-transform-class-properties --save-dev

Если вы хоть раз писали на react, то знаете зачем оно всё нужно. Для остальных просто скажу, что эти все вещи понадобятся нам для того, чтобы во-первых писать на react, во-вторых использовать стильный, модный и молодёжный es6. Есть ещё в третьих и так далее, но это не так важно. У меня нет цели познакомить вас с «прекрасным» миром веба.

Теперь нужно создать ещё две директории — scr для исходников (там будут храниться файлы js и jsx) и static, где будет лежать вся статика — html, js, css (да-да, похоже на Flask или Django!).

Переходим в директорию static и создаём файл index.html с таким содержанием:

Тут тоже ничего нового — обычный html. div id="app" — точка входа в приложение. Теперь нам необходимо создать index.js и App.jsx. В первом мы подключим приложение react к нашей html странице, а во втором будем собственно его и писать.

Переходим в директорию src и создаём App.jsx такого содержания:

Это заглушка нашего будущего приложения. Сейчас всё, что она будет делать — это выводить заголовок «Привет, PyQt + React!». Далее, в этой же директории создаём файл index.js с таким содержимым:

Это стандартная рутинная операция. Мы подключаем наше реакт-приложение к html-странице к тегу div с id="app". Этот файл можно закрыть, мы больше не будем его трогать.

Возвращаемся в корневую директорию нашего проекта, открываем файл package.json и в секции scripts после "test": "echo \"Error: no test specified\" && exit 1" ставим запятую и на следующую строчку добавляем такую команду: "start": "parcel build ./src/index.js -d static --no-source-maps".

Теперь наконец-то можно выполнить в консоли npm start и собрать проект. Если всё прошло удачно (а по-другому быть не должно), то в директории src появится файл index.js. Открывать его смысла особенного нет, так как он не очень читаемый. Теперь можно открыть index.html из папки static с помощью любого браузера и посмотреть на радостное «Привет, PyQt + React!»

Фууу-х! Рутинная часть почти закончена. Как вы могли заметить, parcel используется только как сборщик, никаких функций тестирования не предусмотрено, что не очень хорошо. Я не буду использовать сервер для отладки, так как это не сильно поможет при работе с Qt/PyQt, но вот одна полезная идея есть — открываем файл package.json и в секцию scripts добавляем такую команду: "watch": "parcel watch ./src/index.js -d static". Да, не забывайте про запятую! Теперь можно будет выполнить в консоли npm run watch и отслеживать изменения. В будущем это нам может понадобиться.

Перед тем, как перейти к серверной части, пара моментов. Я использую Sublime Text 3 и Visual Studio Code с разными линтерами. Для того, чтобы писать на стильном, модном и молодёжном es6 и не получать тонны предупреждений рекомендуется сделать следующие вещи (Для VS Code):

  • В корневой директории проекта создайте файл .babelrc следующего содержания (все плагины должны быть установлены)
  • В корневой директории проекта создайте файл .jshintrc следующего содержания

И да пребудет с вами ES6!

Серверная часть. Пишем простое PyQt-приложение

Тут вообще ничего сложного. В корневой директории нашего проекта создаём файл app.py следующего содержания

За исключением импортов это самое тривиальное приложение PyQt. Можно его запустить и посмотреть на пустое окно. Сто́ит пробежаться по импортам:

  • с помощью re мы будем парсить ответ bash.im
  • random будем использовать для того, чтобы взять случайную цитату (от 1 до 10) со страницы случайных цитат bash.im
  • получать страницу будем с помощью requests
  • дадим пользователю возможность перейти на сайт bash.im с помощью webbrowser

Особо обратить внимание нужно на from PyQt5.QtWebEngine import *, from PyQt5.QtWebEngineWidgets import * и from PyQt5.QtWebChannel import *. Это и есть наши новые QtWebEngine и QtWebEngineWidgets (так последнее время принято в Qt — что-то и Widgets), а также загадочный QtWebChannel, с которым мы познакомимся поближе уже совсем скоро.

Соединяем всё вместе!

Пришло время слить два наших приложения в одно! Для начала просто подключим к серверу клиент. Делается это в четыре строчки в методе __init__

Главный тут — QWebView, который просто открывает статическую страницу. Если вдруг после запуска новой версии вас не приветствует надпись «Привет, PyQt + React!», просто выполните в консоли npm start и перезапуститесь. Ура, с нами программа Франкенштейн на PyQt, которая запускает некий QWebView, в котором выполняется react-приложение, предварительно откомпилированное в понятный браузеру javascript… Впереди нас ждёт самое интересное — наладить взаимодействие между клиентским окружением (js/react) и сервером (pyqt/python).

Налаживаем связи

Установка связи между PyQt и React сложнее, чем между PyQt и JavaScript, однако общих моментов достаточно. Сначала докрутим сервер. Тут нам на помощь приходит тот самый загадочный QtWebChannel. В тот же метод __init__ добавляем ещё четыре строчки:

Здесь нужно немного пояснить. Сначала мы объявляем некий экземпляр класса WebClass (имя может быть любым), который обязательно должен быть потомком QObject! Создание класса WebClass будет показано ниже, пока же мы просто оставим заглушку. Затем мы создаём канал, который будет связывать серверный код, который в большинстве своём будет реализован в классе WebClass с клиентским окружением. В третьей строчке мы связываем (точнее даже будет сказать «регистрируем»!) наш экземпляр класса WebClass с произвольным именем в глобальном пространстве имён window. Строго говоря, window это не пространство имён, а глобальный объект javascript, но для нас это не принципиально. Таким образом мы сможем получить доступ к серверному классу WebClass как к объекту javascript window.server (или просто server). Имя "server" также может быть любым. Наконец мы устанавливаем канал на нашу страницу. Мы соединили PyQt с JS!

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

Но сначала небольшое отступление. В документации Qt говорится, что WebClass использует некий файл qwebchannel.js, но вот файла этого нигде нет. По крайней мере, когда я писал это приложение (под PyQt 5.9.1) у меня его не было. Гуглением он был нарыт. Если у вас его тоже нигде нет, то можете скачать его тут или скопировать текст под спойлером.

Файл qwebchannel.js (строго с таким названием!) необходимо положить в директорию static. Также может потребоваться jquery (во всяком случае в моей версии jquery была нужна, хотя в документации к Qt 5.12 про неё не пишут). Jquery можно подключать как локально, так и через cdn. Я выбрал локальный вариант.

Раз мы всё равно будем редактировать клиент, то сразу позаботимся и о внешнем виде приложения. Идея простая — одна кнопка, типа fab в центре экрана для получения цитат и кнопка-гамбургер для управления боковым меню. В боковом меню две возможности — переход на сайт bash.im и выход из приложения. Я использовал библиотеку drawer для создания бокового меню (как на сайте). Также мне захотелось прикрутить анимационные эффекты и я нашёл вот такое решение (спасибо Gabriele Romanato!).

И последнее, но не менее важное — debug! С этим, честно говоря, всё не так чтобы здорово… Несмотря на то, что QtWebEngine базируется на Chromium, я не нашёл информации как именно вызвать инструменты разработчика в QtWebEngine. Пришлось использовать firebug, который иногда сам немного лагает… Про инструменты, которые позволяют дебажить React, я умолчу. Вроде как есть способы запускать React Developer Tools как standalone приложение, но мне оно не понадобилось.

Таким образом, после всех добавлений и украшательств, файл index.html превратился в такой:

Тут очень много всего, поэтому сто́ит остановиться поподробней на некоторых моментах:

  • Добавлены файлы стилей — https://cdnjs.cloudflare.com/ajax/libs/drawer/3.2.2/css/drawer.min.css — для drawer (да-да, они будут получены через cdn!) и style.css — локальный файл для стиля страницы, в котором мы дадим стили кнопке получения цитат.
  • Тег body обзавёлся id и классом. Это также необходимо для использования библиотеки drawer.
  • Сразу после тега body самым первым скриптом обязательно должен быть jquery! В нашем случае он локальный.
  • Строго dev only добавился скрипт, который подгрузит firebug к нашей странице. Для того, чтобы вызвать его, необходимо нажать F12. Я не уверен, что этот скрипт должен быть там, где он есть, возможно его надо опустить в самый низ, перед локальными скриптами.
  • Скрипт qwebchannel.js должен идти строго перед скриптом, в котором мы будем связывать сервер с клиентом, в нашем случае перед приложением React. Скрипт строго обязателен!
  • Скрипты для библиотеки drawer (jquery был загружен выше)
  • В локальном скрипте, который написан прям в html-файле стандартная рутина — инициализация drawer и решение Gabriele Romanato по анимации. Можно было отделить их и написать всё в отдельном файле, но я оставил это специально для того, чтобы показать как QtWebEngine обрабатывает всевозможные скрипты — cdn, локальные и местные.
  • Строго после всего это должна быть точка входа в реакт-приложение и сам скрипт приложения.

Осталось ещё немного рутины — нам нужно написать файл стилей. Он прост, поэтому не требует комментариев:

Его также необходимо положить в директорию static. Таким образом, в папке static будет четыре наших файла — style.css, index.html, qwebchannel.js и jquery.js. Пятым будет index.js, который будет создавать для нас parcel.

Если сейчас запустить наше приложение (не важно, пересобирали ли мы react приложение) и нажать F12, то мы увидим окно firebug и предупреждение в консоли о том, что jquery что-то там не смогла сделать… Мы не увидим ни нашего дизайна, ни кнопок, ни анимации. Более того, если мы постараемся из консоли получить доступ к window.server, то мы тоже получим undefined.

Пришло время это исправить! Открываем файл App.jsx и наконец-то начинаем соединять сервер с клиентом. Для этого нам необходимо написать метод componentDidMount(). Если вы знакомы с React, то вы понимаете почему именно здесь нужно писать связь. Реализация метода проста:

В принципе, должно быть понятно, что именно тут происходит. Важные моменты — qwebchannel.js должен быть загружен в коде до того, как начнёт отрабатывать приложение React, иначе получите ошибку Uncaught ReferenceError: qt is not defined; в вызове new QWebChannel первый параметр должен быть qt.webChannelTransport, если же вы пишете на QML, то navigator.qtWebChannelTransport

Теперь можно выполнить npm start, запустить наше приложение, открыть firebug, и наконец-то в консоли увидеть, что server — это некий объект. Да, теперь мы точно соединили сервер с клиентом!

Заканчиваем с внешним видом!

Пришло время уже́ заставить наше приложение отрисовываться так, как нам хочется! Для этого нужно изменить метод render(). Вместо унылого «Привет PyQt + React!» мы напишем следующее:

Эта разметка не претендует на какое-то откровение, большая часть просто взята со страницы библиотеки drawer. Есть, однако, вещь, которая сто́ит того, чтобы на неё обратить внимание. Почему-то (может быть это только у меня) мы обязаны использовать конструкции типа onClick={() => console.log("away")}. Попытка не использовать стрелочные функции привела меня к тому, что ничего не работало.

В данный момент у нас есть три заглушки для методов, которые будут вызывать серверный код. У нас есть клик по ссылке «ПЕРЕЙТИ НА BASH.IM», который откроет нам сайт в браузере по умолчанию (вне текущего приложения!), клик по ссылке «ВЫХОД», который закроет приложение PyQt, и клик по кнопке «+», который вызовет серверный код для получения случайной цитаты. Кстати говоря, мы могли бы получать цитату и из React-кода, к примеру с помощью axios, но в данный момент нам это не нужно.

Теперь можно скомпилировать react-приложение, запустить app.py и увидеть наш минималистичный дизайн. Пусть вас не смущает, если при загрузке страницы всё дёргается — это связано с неспешной загрузкой firebug, когда мы её уберём такого не будет. Открыв консоль и потыкав в кнопки, можно увидеть, что react-приложение работает должным образом. Значит, пришло время реализовывать наши методы.

Реализуем методы

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

Метод должен быть реализован в классе WebClass и мы обязаны использовать декоратор @pyqtSlot(). Именно поэтому WebClass должен быть наследником QObject. Мы не передаём в наш код никаких параметров с клиента, поэтому декоратор идёт с пустыми скобками. Если бы нам нужно было передать какие-либо параметры, то мы должны были бы указать тип, например так @pyqtSlot(int).

Теперь наша задача на клиенте просто вызвать серверный метод. Так как наш WebClass для клиента является объектом window.server, то мы можем просто взять его и вызвать! Изменим наш react-код:

Компилируем, запускаем, выполняем! Профит! Мы только что осуществили взаимодействие между React и PyQt! На клиенте мы вызвали серверный код! Если бы мы захотели передать серверу какие-то параметры, то просто написали бы как-то так server.method(arg), а на сервере наш декоратор должен был бы знать тип передаваемого значения arg, например как-то так — pyqtSlot(str). Поскольку по сути мы передаём пустое значение (), то и декоратор тоже пуст.

Логично предположить, что и второй наш метод, который требует открытия браузера можно и нужно реализовать аналогично! Можно сразу изменить код на клиенте:

Теперь наша задача сводится к тому, чтобы на сервере реализовать метод away(), который не принимает никаких аргументов и по сути имеет тип возвращаемого значения void, то есть ничего не возвращает. Реализация достаточно банальна:

Компилируем, запускаем, выполняем! Ну вы поняли…

Третья задача интересней и сложней. Мы должны получить от клиента команду, выполнить код и отдать клиенту данные! То есть наш метод будет что-то возвращать. Забавно, но код, который предстоит нам написать для React и для PyQt удивительно похож! Я думаю, если вы знаете что-то о React (а иначе что вы тут делаете??), то вы знаете и понимаете, что такое state. Мы будем манипулировать этим самым состоянием для обновления компонента. Для начала давайте напишем constructor:

Мы говорим, что наш компонент будет принимать некое состояние this.state.quote, которое по умолчанию пусто. Кроме того, нам бы хотелось красивые эффекты, при смене цитаты. Я выбрал классику — FadeIn/FadeOut. Давайте сразу напишем метод, который будет вызывать эффект FadeIn при смене цитаты.

Выше в конструкторе мы связываем метод — this.update.bind(this) — стандартная рутина для React. Сам метод update() просто находит элемент с id fade и применяет к нему эффект fadeIn за 1 секунду. Теперь нам осталось только изменить ту часть контейнера, где будут отображаться цитаты:

Да, мы используем dangerouslySetInnerHTML={{ __html: this.state.quote }}, так как знаем, что с сайта мы будем получать html-код, который и будем вставлять в наш div. Теперь нам осталось только разобраться с кликом по кнопке. По сути своей — это просто вызов серверного метода. Мы снова не передаём в сервер никаких параметров, мы уже писали такое дважды. Напишем в третий раз:

Опять же неважно как именно мы обзовём метод сервера — хоть getQuote(), хоть qwerty123(), главное, чтобы мы реализовали метод с точно таким же названием на сервере в классе WebClass! Хорошо, реализуем:

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

Оказывается тут нам на помощь приходит макрос Q_PROPERTY, а точнее его питоновская реализация. Перепишем наш WebClass так:

Сначала мы создаём сигнал quote_changed (имя может быть любым) с параметром str — это тип нашей цитаты. Затем мы создаём свойство self.quote. Обязательно используем декоратор @pyqtProperty(str) с типом! Второй аргумент необязателен, более того, насколько я понял, в python он вообще не используется, но notify=quote_changed позволяет указать, какой сигнал будет вызываться при изменении данного свойства. Это может быть полезно, если у вас тонна разных свойств и сигналов. Сеттер для self.quote прост, но в нём обязательно нужно вызвать self.quote_changed.emit(new_value), чтобы отработал сигнал quote_changed. И, наконец, последнее — в методе self.getQuote() мы обязательно связываем полученный результат со свойством self.quote!

На самом деле, так положено делать (вроде как), но можно считерить 🙂 Удалим весь код из предыдущей вставки и наш метод getQuote() и напишем так

То есть мы сразу вызываем сигнал, в который передаём значение. Это позволяет сильно сократить код. Впрочем, если мы прямо сейчас запустим наше приложение, то ничего смешного с баша мы не получим. Дело в том, что нам надо связать сигнал со слотом. И делать это мы будем… на клиенте!

Во-о-о-т! Мы добавили connect для связи со слотом, вот только сделали мы это в реакт-приложении. Слотом у нас выступает стрелочная функция, которая меняет состояние this.state.quote и вызывает анимационные эффекты. Остальное берёт на себя React! Компилируем, запускаем, выполняем!

Ложка юзабилити

По сути наше приложение написано. Остались мелкие штрихи. Сначала откроем файл index.html и удалим скрипт для firebug. Это значительно ускорит загрузку страницы и практически полностью уберёт скакание элементов (почему-то иногда элементы всё равно могут это делать). Теперь перейдём к серверу. Раз мы реализовали выход из приложения с помощью react, то неплохо было бы запускать его в полноэкранном режиме. Достигается это одной строчкой кода. В метод __init__ класса MainWindow в самый низ добавим

и получим желаемое. Ещё хотелось бы не кликать мышкой по кнопке, а задать какую-нибудь комбинацию клавиш для получения новых цитат. Хотя, зачем комбинацию? F5 мне вполне подойдёт! И кстати, я ещё не показал как напрямую вызвать код javascript с сервера. Так давайте сделаем это (код должен быть в классе MainWindow):

Метод keyPressEvent просто вызывает метод getRandom при нажатии клавиши F5. А вот сам getRandom куда интереснее! Мы обращаемся напрямую к странице и просто выполняем произвольный код javascript. В нашем случае мы «тушим» текущую цитату перед получением новой. Примечательно, что метод runJavaScript вторым параметром принимает callback, то есть мы можем сразу после вызова javascript вызвать ещё что-то, например нашу getQuote(). Внешние эффекты (которые от вызова javascript) не столь заметны в нашем случае, зато мы теперь получаем цитаты по нажатию клавиши F5.

С точки зрения удобства было бы ещё неплохо открывать/закрывать боковое меню по нажатию клавиши Esc (ну к примеру). Поскольку Qt не очень дружит с этой клавишей (так уж повелось), для корректной работы надо написать свой eventFilter. Пишем:

и устанавливаем (в самый конец метода __init__)

Мы снова вызываем код javascript из python, но используем jquery (просто обучения ради). Запустив приложение, можно убедиться, что всё работает так, как хочется. В общем-то и всё!

Заключение

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

  1. Медленная загрузка даже статической страницы — забавно, но pyqt справляется лучше (значительно лучше!), чем родной Qt. Не знаю почему, но в чистом Qt страница очень не торопится отрисоваться.
  2. Ограниченность самой технологии — по крайней мере на момент написания моего первого приложения и активного гугления требовался компилятор MSVC 64 bit. То есть огромный пласт разработчиков с MinGW, с 32 bit компиляторами просто в пролёте. Надеюсь, что это уже не актуально или станет не актуальным со временем…
  3. Трудность дебага. Особенно если писать на React. С чистым javascript несколько проще.
  4. Скомпилированное (точнее замороженное с помощью cx_freeze) приложение получается не сильно меньше приложения electron, которое весит далеко за 100 Мб… Хотелось бы меньше
  5. Если сервер будет выполнять тяжёлые операции, то интерфейс приложения замирает (что логично). Решение элементарно — тяжеловесные функции, функции, которые получают что-то из интернета и проч. должны выполняться в отдельных потоках. Это не является таким уж минусом, просто об этом нужно знать и помнить. В нашем случая я пренебрёг этим правилом, так как для нас не критично застывание интерфейса в ожидании новой цитаты…

Открытым также остаётся вопрос по потреблению памяти. Общеизвестна прожорливость electron, который в свою очередь базируется на том же самом Chromium. Я ничего не могу сказать по PyQt/Qt — не проводил исследований. По субъективным ощущениям гибриды PyQt/Qt потребляют память не так активно, как electron. Плюсы:

  1. Возможность использовать любую веб-технологию для построения абсолютно любого интерфейса. QML нервно курит в сторонке.
  2. Возможность создавать гибридные интерфейсы — к примеру QSplitter, в одной части которого QWebEngineView с приложением на JS/React, а в другом QTableView, связанный с QSqlTableModel
  3. Возможность использовать сильные стороны «серверного» языка. Будь то тяжеленные вычисления (C++) или вообще что угодно (python)…
  4. Возможность быстро адаптировать любой сайт/веб-продукт для десктопа.
  5. Возможность раздельного написания (одни люди пишут интерфейс, другие логику).

В отличие от приложений electron, в Qt/PyQt проще взаимодействие между интерфейсной и «серверной» частями, проще взаимодействие с файловой системой (ИМХО, конечно). В зависимости от конкретной ситуации можно использовать только сильные стороны языка (например С++ и тяжёлые вычисления, но JS для разбора JSON или python и разбор JSON, но chart.js для построения графиков). Одна только возможность расширять javascript-движки для игр смотрится крайне заманчивой! Надеюсь, что технология будет востребована и будет развиваться, желательно семимильными шагами, а я прощаюсь!

Следующая статья будет посвящена созданию гибридного приложения на Qt и Javascript (не React!).

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.




Один комментарий на Гибридное приложение PyQt + React

  1. […] эпос, как и было обещано в статье Гибридное приложение PyQt + React, будет посвящён разработке гибридного приложения на […]

Добавить комментарий

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: